mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 09:03:11 +00:00
chore(iam): delete leftover Lambda execution roles (PLAT-218)
This commit is contained in:
parent
26e9716df4
commit
34df964263
5 changed files with 40 additions and 316 deletions
28
README.md
28
README.md
|
|
@ -58,16 +58,18 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules
|
|||
|
||||
## Architecture
|
||||
|
||||
- **Runtime**: Python 3.12 on ECS Fargate (arm64) plus dual-run Lambdas until cutover. seahaven-prod `011934824531`, seahaven-dev `710827005802`
|
||||
- **Runtime**: Python 3.12 on ECS Fargate (arm64). Cutover completed 2026-09-21. seahaven-prod `011934824531`, seahaven-dev `710827005802`
|
||||
- **Data**: DynamoDB single-table (`afterhours-shifts`)
|
||||
- **IaC**: HCP Terraform workspaces tagged `app:afterhours-shift-manager` (`afterhours-shift-manager-dev` / `-prod`) plus GitHub Actions `deploy-api.yaml` (image). Terraform does not package `src/`.
|
||||
- **Slack**: Slack Bolt on `POST /slack/events`
|
||||
- **3CX Integration**: Queue routing updated directly via 3CX Queue XAPI
|
||||
- **Secrets**: AWS Secrets Manager (`afterhours-shift-manager/*`)
|
||||
|
||||
### Lambda Functions
|
||||
### Leftover zip handlers (packaging only)
|
||||
|
||||
| Function | Trigger | Purpose |
|
||||
Fargate is the live path. These `src/*/app.py` zip sources remain for `scripts/package_lambdas.py` only. Leftover Lambda IAM roles were removed after Paychex dropped AfterhoursWeeklyPostSend (PLAT-218).
|
||||
|
||||
| Handler | Former trigger | Purpose |
|
||||
|---|---|---|
|
||||
| `afterhours-shift-manager` | API Gateway (POST /slack/events) | Slack bot — handles `/oncall` commands and interactive buttons |
|
||||
| `afterhours-weekly-post` | EventBridge (Monday 7am ET) | Posts weekly schedule to Slack, sends pay report email |
|
||||
|
|
@ -81,17 +83,17 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules
|
|||
```
|
||||
src/
|
||||
server/ Flask + gunicorn + SQS worker (Fargate)
|
||||
slack-bot/ Slack Bolt app; Lambda handler until cutover; ships CHANGELOG.md for App Home
|
||||
weekly-post/ Monday schedule + pay post
|
||||
roster-sync/ Daily 3CX roster sync
|
||||
roster-api/ HTTP PUT/DELETE /roster for identity hire/offboard
|
||||
portal-api/ Cognito employee/admin shift API for the internal portal
|
||||
ring-scheduler/ 3CX queue routing updates
|
||||
holiday-router/ 3CX IVR/queue repoint for holiday day shifts (activate/deactivate)
|
||||
shared/ Bundled into each function zip and the Fargate image
|
||||
terraform/ HCP Terraform (Lambda skeletons, ECS/ALB, API, DDB, IAM, schedules)
|
||||
slack-bot/ Slack Bolt app; leftover zip source for packaging; ships CHANGELOG.md for App Home
|
||||
weekly-post/ Monday schedule + pay post (leftover zip source for packaging)
|
||||
roster-sync/ Daily 3CX roster sync (leftover zip source for packaging)
|
||||
roster-api/ HTTP PUT/DELETE /roster for identity hire/offboard (leftover zip source for packaging)
|
||||
portal-api/ Cognito employee/admin shift API for the internal portal (leftover zip source for packaging)
|
||||
ring-scheduler/ 3CX queue routing updates (leftover zip source for packaging)
|
||||
holiday-router/ 3CX IVR/queue repoint for holiday day shifts (leftover zip source for packaging)
|
||||
shared/ Bundled into leftover function zips and the Fargate image
|
||||
terraform/ HCP Terraform (ECS/ALB, API, DDB, IAM, leftover zip packaging locals, schedules)
|
||||
scripts/ in-package changelog copy sync + cutover
|
||||
tests/ pytest suite (mirrors src/, one dir per Lambda + shared + server)
|
||||
tests/ pytest suite (mirrors src/, one dir per leftover zip handler + shared + server)
|
||||
```
|
||||
|
||||
### DynamoDB Schema
|
||||
|
|
|
|||
5
SETUP.md
5
SETUP.md
|
|
@ -115,9 +115,8 @@ and `ecs_schedules_enabled=false` until the Fargate cutover below.
|
|||
|
||||
HCP outputs to copy: `slack_request_url`, `api_origin`, `fargate_origin`,
|
||||
`holiday_scheduler_role_arn`, `github_deploy_role_arn`, `jobs_queue_arn`,
|
||||
`ecs_task_role_arn`. Add `ecs_task_role_arn` to paychex-checkcomponents
|
||||
(alongside `afterhours-shift-manager-weekly-post`) before Fargate weekly_post
|
||||
runs. Dual-run keeps the Lambda principal until zip CD is retired.
|
||||
`ecs_task_role_arn`. Paychex checkcomponents allows `afterhours-shift-manager-api`;
|
||||
leftover weekly-post principal is gone.
|
||||
|
||||
## 4. Set the Slack Request URL
|
||||
|
||||
|
|
|
|||
|
|
@ -1,291 +1,3 @@
|
|||
# Leftover Lambda IAM roles from dual-run. The seven functions are gone;
|
||||
# weekly-post remains so paychex-checkcomponents can keep that principal
|
||||
# until a follow-up queue-policy apply drops it.
|
||||
|
||||
data "aws_iam_policy_document" "lambda_assume" {
|
||||
statement {
|
||||
effect = "Allow"
|
||||
actions = ["sts:AssumeRole"]
|
||||
|
||||
principals {
|
||||
type = "Service"
|
||||
identifiers = ["lambda.amazonaws.com"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
table_arn = aws_dynamodb_table.shifts.arn
|
||||
|
||||
lambda_identity = {
|
||||
slack_bot = [
|
||||
{
|
||||
sid = "DdbCrud"
|
||||
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"]
|
||||
resources = [local.table_arn, "${local.table_arn}/*"]
|
||||
condition = null
|
||||
},
|
||||
{
|
||||
sid = "Secrets"
|
||||
actions = ["secretsmanager:GetSecretValue"]
|
||||
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/*"]
|
||||
condition = null
|
||||
},
|
||||
{
|
||||
sid = "HolidaySchedules"
|
||||
actions = ["scheduler:CreateSchedule", "scheduler:DeleteSchedule", "scheduler:GetSchedule"]
|
||||
resources = ["arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*"]
|
||||
condition = null
|
||||
},
|
||||
{
|
||||
sid = "PassHolidayScheduler"
|
||||
actions = ["iam:PassRole"]
|
||||
resources = [local.holiday_scheduler_role_arn]
|
||||
condition = {
|
||||
test = "StringEquals"
|
||||
variable = "iam:PassedToService"
|
||||
values = ["scheduler.amazonaws.com"]
|
||||
}
|
||||
},
|
||||
{
|
||||
sid = "InvokeHolidayRouter"
|
||||
actions = ["lambda:InvokeFunction"]
|
||||
resources = [local.holiday_router_arn]
|
||||
condition = null
|
||||
},
|
||||
]
|
||||
weekly_post = concat(
|
||||
[
|
||||
{
|
||||
sid = "DdbCrud"
|
||||
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"]
|
||||
resources = [local.table_arn, "${local.table_arn}/*"]
|
||||
condition = null
|
||||
},
|
||||
{
|
||||
sid = "Secrets"
|
||||
actions = ["secretsmanager:GetSecretValue"]
|
||||
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/slack-bot-token-*"]
|
||||
condition = null
|
||||
},
|
||||
],
|
||||
var.checkcomponents_queue_arn == "" ? [] : [
|
||||
{
|
||||
sid = "CheckcomponentsSend"
|
||||
actions = ["sqs:SendMessage"]
|
||||
resources = [var.checkcomponents_queue_arn]
|
||||
condition = null
|
||||
},
|
||||
],
|
||||
),
|
||||
roster_sync = [
|
||||
{
|
||||
sid = "DdbCrud"
|
||||
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"]
|
||||
resources = [local.table_arn, "${local.table_arn}/*"]
|
||||
condition = null
|
||||
},
|
||||
{
|
||||
sid = "Secrets"
|
||||
actions = ["secretsmanager:GetSecretValue"]
|
||||
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/3cx-*"]
|
||||
condition = null
|
||||
},
|
||||
]
|
||||
roster_api = [
|
||||
{
|
||||
sid = "DdbWrite"
|
||||
actions = ["dynamodb:UpdateItem", "dynamodb:DeleteItem"]
|
||||
resources = [local.table_arn]
|
||||
condition = null
|
||||
},
|
||||
{
|
||||
sid = "Secrets"
|
||||
actions = ["secretsmanager:GetSecretValue"]
|
||||
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/roster-api-token-*"]
|
||||
condition = null
|
||||
},
|
||||
]
|
||||
ring_scheduler = [
|
||||
{
|
||||
sid = "DdbRead"
|
||||
actions = ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:DescribeTable"]
|
||||
resources = [local.table_arn, "${local.table_arn}/*"]
|
||||
condition = null
|
||||
},
|
||||
{
|
||||
sid = "Secrets"
|
||||
actions = ["secretsmanager:GetSecretValue"]
|
||||
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/3cx-*"]
|
||||
condition = null
|
||||
},
|
||||
]
|
||||
holiday_router = [
|
||||
{
|
||||
sid = "DdbCrud"
|
||||
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"]
|
||||
resources = [local.table_arn, "${local.table_arn}/*"]
|
||||
condition = null
|
||||
},
|
||||
{
|
||||
sid = "Secrets"
|
||||
actions = ["secretsmanager:GetSecretValue"]
|
||||
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/3cx-*"]
|
||||
condition = null
|
||||
},
|
||||
]
|
||||
portal_api = [
|
||||
{
|
||||
sid = "DdbCrud"
|
||||
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"]
|
||||
resources = [local.table_arn, "${local.table_arn}/*"]
|
||||
condition = null
|
||||
},
|
||||
{
|
||||
sid = "Secrets"
|
||||
actions = ["secretsmanager:GetSecretValue"]
|
||||
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/*"]
|
||||
condition = null
|
||||
},
|
||||
{
|
||||
sid = "HolidaySchedules"
|
||||
actions = ["scheduler:CreateSchedule", "scheduler:DeleteSchedule", "scheduler:GetSchedule"]
|
||||
resources = ["arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*"]
|
||||
condition = null
|
||||
},
|
||||
{
|
||||
sid = "PassHolidayScheduler"
|
||||
actions = ["iam:PassRole"]
|
||||
resources = [local.holiday_scheduler_role_arn]
|
||||
condition = {
|
||||
test = "StringEquals"
|
||||
variable = "iam:PassedToService"
|
||||
values = ["scheduler.amazonaws.com"]
|
||||
}
|
||||
},
|
||||
{
|
||||
sid = "InvokeHolidayRouter"
|
||||
actions = ["lambda:InvokeFunction"]
|
||||
resources = [local.holiday_router_arn]
|
||||
condition = null
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
lambda_env = {
|
||||
slack_bot = {
|
||||
SHIFT_TABLE = aws_dynamodb_table.shifts.name
|
||||
SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token"
|
||||
SLACK_SIGNING_SECRET = "afterhours-shift-manager/slack-signing-secret"
|
||||
SHIFT_CHANNEL = var.shift_channel
|
||||
TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-"
|
||||
QUEUE_NUMBER = var.queue_number
|
||||
TZ = var.timezone
|
||||
HOLIDAY_ROUTER_ARN = local.holiday_router_arn
|
||||
HOLIDAY_SCHEDULER_ROLE_ARN = local.holiday_scheduler_role_arn
|
||||
SENTRY_DSN = var.sentry_dsn
|
||||
}
|
||||
weekly_post = {
|
||||
SHIFT_TABLE = aws_dynamodb_table.shifts.name
|
||||
SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token"
|
||||
SHIFT_CHANNEL = var.shift_channel
|
||||
PAY_REPORT_USER = var.pay_report_user
|
||||
TZ = var.timezone
|
||||
CHECKCOMPONENTS_QUEUE_URL = var.checkcomponents_queue_url
|
||||
SENTRY_DSN = var.sentry_dsn
|
||||
}
|
||||
roster_sync = {
|
||||
SHIFT_TABLE = aws_dynamodb_table.shifts.name
|
||||
TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-"
|
||||
SYNC_GROUP = "DEFAULT"
|
||||
TZ = var.timezone
|
||||
SENTRY_DSN = var.sentry_dsn
|
||||
}
|
||||
roster_api = {
|
||||
SHIFT_TABLE = aws_dynamodb_table.shifts.name
|
||||
ROSTER_API_TOKEN_SECRET = "afterhours-shift-manager/roster-api-token"
|
||||
TZ = var.timezone
|
||||
SENTRY_DSN = var.sentry_dsn
|
||||
}
|
||||
ring_scheduler = {
|
||||
SHIFT_TABLE = aws_dynamodb_table.shifts.name
|
||||
TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-"
|
||||
QUEUE_NUMBER = var.queue_number
|
||||
TZ = var.timezone
|
||||
SENTRY_DSN = var.sentry_dsn
|
||||
}
|
||||
holiday_router = {
|
||||
SHIFT_TABLE = aws_dynamodb_table.shifts.name
|
||||
TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-"
|
||||
TZ = var.timezone
|
||||
SENTRY_DSN = var.sentry_dsn
|
||||
}
|
||||
portal_api = {
|
||||
SHIFT_TABLE = aws_dynamodb_table.shifts.name
|
||||
SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token"
|
||||
SHIFT_CHANNEL = var.shift_channel
|
||||
TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-"
|
||||
QUEUE_NUMBER = var.queue_number
|
||||
TZ = var.timezone
|
||||
HOLIDAY_ROUTER_ARN = local.holiday_router_arn
|
||||
HOLIDAY_SCHEDULER_ROLE_ARN = local.holiday_scheduler_role_arn
|
||||
SENTRY_DSN = var.sentry_dsn
|
||||
PORTAL_COGNITO_ISSUER = var.portal_cognito_issuer
|
||||
PORTAL_COGNITO_AUDIENCE = var.portal_cognito_audience
|
||||
PORTAL_COGNITO_TRUST = jsonencode(concat(
|
||||
var.portal_cognito_issuer != "" && var.portal_cognito_audience != "" ? [{ issuer = var.portal_cognito_issuer, audience = var.portal_cognito_audience }] : [],
|
||||
var.portal_cognito_extra_trust,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "lambda" {
|
||||
for_each = local.functions
|
||||
|
||||
name = each.value.role_name
|
||||
path = "/tf-managed/"
|
||||
description = "Lambda execution role for ${each.value.function_name}"
|
||||
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
|
||||
permissions_boundary = aws_iam_policy.lambda_boundary.arn
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "lambda" {
|
||||
for_each = local.functions
|
||||
|
||||
dynamic "statement" {
|
||||
for_each = local.lambda_identity[each.key]
|
||||
|
||||
content {
|
||||
sid = statement.value.sid
|
||||
effect = "Allow"
|
||||
actions = statement.value.actions
|
||||
resources = statement.value.resources
|
||||
|
||||
dynamic "condition" {
|
||||
for_each = try(statement.value.condition, null) == null ? [] : [statement.value.condition]
|
||||
|
||||
content {
|
||||
test = condition.value.test
|
||||
variable = condition.value.variable
|
||||
values = condition.value.values
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "lambda" {
|
||||
for_each = local.functions
|
||||
|
||||
name = each.key
|
||||
role = aws_iam_role.lambda[each.key].id
|
||||
policy = data.aws_iam_policy_document.lambda[each.key].json
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "lambda_basic" {
|
||||
for_each = local.functions
|
||||
|
||||
role = aws_iam_role.lambda[each.key].name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
||||
}
|
||||
# Leftover Lambda execution roles were removed after Paychex dropped
|
||||
# AfterhoursWeeklyPostSend (PLAT-218). Zip handlers in src/*/app.py remain for
|
||||
# packaging via scripts/package_lambdas.py.
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ output "jobs_queue_arn" {
|
|||
}
|
||||
|
||||
output "ecs_task_role_arn" {
|
||||
description = "ECS task role. paychex-checkcomponents queue policy must allow this ARN before Fargate weekly_post."
|
||||
description = "ECS task role. Paychex already allows this ARN."
|
||||
value = aws_iam_role.ecs_task.arn
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,16 @@ from pathlib import Path
|
|||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TERRAFORM = ROOT / "terraform"
|
||||
LAMBDA_TF = (TERRAFORM / "lambda.tf").read_text()
|
||||
HCP_IAM = (TERRAFORM / "hcp_iam.tf").read_text()
|
||||
CI = (ROOT / ".github" / "workflows" / "ci.yaml").read_text()
|
||||
LOCALS = (TERRAFORM / "locals.tf").read_text()
|
||||
VARIABLES = (TERRAFORM / "variables.tf").read_text()
|
||||
|
||||
|
||||
def _tf_without_comments(text: str) -> str:
|
||||
return "\n".join(line.split("#", 1)[0] for line in text.splitlines())
|
||||
|
||||
|
||||
def test_sam_template_removed():
|
||||
assert not (ROOT / "template.yaml").exists()
|
||||
assert not (ROOT / "samconfig.toml.example").exists()
|
||||
|
|
@ -18,7 +21,8 @@ def test_sam_template_removed():
|
|||
|
||||
def test_zip_cd_removed():
|
||||
assert not (ROOT / ".github" / "workflows" / "deploy.yaml").exists()
|
||||
assert 'resource "aws_lambda_function"' not in LAMBDA_TF
|
||||
for path in TERRAFORM.glob("*.tf"):
|
||||
assert 'resource "aws_lambda_function"' not in path.read_text()
|
||||
assert not (TERRAFORM / "apigateway.tf").exists()
|
||||
assert not (TERRAFORM / "events.tf").exists()
|
||||
|
||||
|
|
@ -120,7 +124,8 @@ def test_ci_runs_pytest_and_terraform_validate():
|
|||
|
||||
def test_checkcomponents_queue_arn_variable_matches_iam_references():
|
||||
assert 'variable "checkcomponents_queue_arn"' in VARIABLES
|
||||
assert "var.checkcomponents_queue_arn" in LAMBDA_TF
|
||||
iam = (TERRAFORM / "iam.tf").read_text()
|
||||
assert "var.checkcomponents_queue_arn" in iam
|
||||
boundary = (TERRAFORM / "lambda_boundary.tf").read_text()
|
||||
assert "var.checkcomponents_queue_arn" in boundary
|
||||
data_tf = (TERRAFORM / "data.tf").read_text()
|
||||
|
|
@ -128,7 +133,16 @@ def test_checkcomponents_queue_arn_variable_matches_iam_references():
|
|||
assert "checkcomponents_pair" in data_tf
|
||||
|
||||
|
||||
def test_seven_function_names_still_on_leftover_roles():
|
||||
def test_leftover_lambda_iam_roles_removed():
|
||||
for path in TERRAFORM.glob("*.tf"):
|
||||
body = _tf_without_comments(path.read_text())
|
||||
assert 'resource "aws_iam_role" "lambda"' not in body
|
||||
assert 'resource "aws_iam_role_policy" "lambda"' not in body
|
||||
assert 'resource "aws_iam_role_policy_attachment" "lambda_basic"' not in body
|
||||
assert 'data "aws_iam_policy_document" "lambda_assume"' not in body
|
||||
|
||||
|
||||
def test_local_functions_still_lists_packaging_keys():
|
||||
for name in (
|
||||
"afterhours-shift-manager",
|
||||
"afterhours-weekly-post",
|
||||
|
|
@ -140,10 +154,7 @@ def test_seven_function_names_still_on_leftover_roles():
|
|||
):
|
||||
assert name in LOCALS
|
||||
assert "afterhours-release-notifier" not in LOCALS
|
||||
|
||||
|
||||
def test_weekly_post_role_is_tf_managed_name():
|
||||
assert 'role_name = "afterhours-shift-manager-weekly-post"' in LOCALS
|
||||
assert "weekly_post" in LOCALS
|
||||
|
||||
|
||||
def test_github_deploy_trust_covers_image_only():
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue