Skip to content

Commit da547b5

Browse files
committed
feat: add seed
1 parent 84fd245 commit da547b5

23 files changed

Lines changed: 977 additions & 23 deletions

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,8 @@ ACCOUNT_LOCKOUT_WINDOW_MINUTES=15
3434
ACCOUNT_LOCKOUT_DURATION_MINUTES=15
3535

3636
LOG_FORMAT=json
37+
38+
SEED_ADMIN_EMAIL=
39+
SEED_ADMIN_PASSWORD=
40+
SEED_ADMIN_USERNAME=admin
41+
SEED_ADMIN_FULLNAME=System Administrator

Makefile

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ COMPOSE_FILE := docker-compose.yml
1010

1111
.DEFAULT_GOAL := help
1212

13-
.PHONY: help install run test lint import-check security-scan check migrate downgrade revision db-up db-down db-logs clean
13+
.PHONY: help install run test lint import-check security-scan check migrate seed downgrade revision db-up db-down db-logs clean
1414

1515
help:
1616
@echo "[make:help] Available commands:"
@@ -22,6 +22,7 @@ help:
2222
@echo " [make:security-scan] Run dependency vulnerability scan with pip-audit"
2323
@echo " [make:check] Run tests, lint, and import check"
2424
@echo " [make:migrate] Apply Alembic migrations"
25+
@echo " [make:seed] Seed baseline database records"
2526
@echo " [make:downgrade] Roll back one Alembic migration"
2627
@echo " [make:revision] Create an Alembic migration: make revision name=\"describe change\""
2728
@echo " [make:db-up] Start Docker Compose services"
@@ -43,7 +44,7 @@ test:
4344

4445
lint:
4546
@echo "[make:lint] Running Ruff checks"
46-
@$(RUFF) check src tests
47+
@$(RUFF) check src tests scripts
4748

4849
import-check:
4950
@echo "[make:import-check] Verifying src.main imports"
@@ -64,6 +65,10 @@ migrate:
6465
@echo "[make:migrate] Applying Alembic migrations"
6566
@$(ALEMBIC) upgrade head
6667

68+
seed:
69+
@echo "[make:seed] Running database seeders"
70+
@$(PYTHON) scripts/seed.py
71+
6772
downgrade:
6873
@echo "[make:downgrade] Rolling back one Alembic migration"
6974
@$(ALEMBIC) downgrade -1

README.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,12 +323,32 @@ With Make:
323323

324324
```bash
325325
make migrate
326+
make seed
326327
make revision name="add todo due date"
327328
make downgrade
328329
```
329330

330331
Important: migration autogeneration depends on importing all SQLAlchemy models in `alembic/env.py`, so new module models must be imported there or through a central model registry.
331332

333+
Seed baseline authorization data after applying migrations:
334+
335+
```bash
336+
make seed
337+
```
338+
339+
The seeder is idempotent. It creates default authorization resources, the default `admin` and `user` roles, default permissions, role-permission links, and matching Casbin policies without duplicating existing records.
340+
341+
To seed an initial admin user, set these environment variables before running `make seed`:
342+
343+
```env
344+
SEED_ADMIN_EMAIL=admin@example.com
345+
SEED_ADMIN_PASSWORD=
346+
SEED_ADMIN_USERNAME=admin
347+
SEED_ADMIN_FULLNAME=System Administrator
348+
```
349+
350+
If `SEED_ADMIN_EMAIL` or `SEED_ADMIN_PASSWORD` is empty, user seeding is skipped. Existing users are not modified.
351+
332352
## Testing and Quality Checks
333353

334354
Run tests:
@@ -352,7 +372,7 @@ make check
352372
Current check set:
353373

354374
- `pytest -q`
355-
- `ruff check src tests`
375+
- `ruff check src tests scripts`
356376
- import check for `src.main`
357377

358378
## Makefile Commands
@@ -366,6 +386,7 @@ make lint
366386
make import-check
367387
make check
368388
make migrate
389+
make seed
369390
make downgrade
370391
make revision name="migration message"
371392
make db-up
@@ -527,7 +548,6 @@ Legend: `Implemented` means code exists in the repository. `Partial` means code
527548

528549
## Known Notes
529550

530-
- `alembic/env.py` currently prints metadata debug output during migrations.
531551
- `src/core/lifespan.py` still calls `Base.metadata.create_all`; with Alembic in place, production environments normally rely on migrations instead.
532552
- The project has a Pydantic v2 deprecation warning for class-based settings config.
533553
- The Dockerfile start script path needs alignment before relying on Docker builds.

alembic/env.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
from src.core.authorization.infrastructure.models.permission_model import (
1414
PermissionModel, # noqa: F401
1515
)
16+
from src.core.authorization.infrastructure.models.resource_model import (
17+
AuthorizationResourceModel, # noqa: F401
18+
)
1619
from src.core.authorization.infrastructure.models.role_model import (
1720
RoleModel, # noqa: F401
1821
)
@@ -41,10 +44,6 @@
4144

4245
settings = get_settings()
4346

44-
print(
45-
"🔍 ALEMBIC DEBUG: Tables found in metadata ->", list(Base.metadata.tables.keys())
46-
)
47-
4847
config = context.config
4948

5049
if config.config_file_name is not None:
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""rename authorization description columns
2+
3+
Revision ID: c7a1b9e5d4f2
4+
Revises: b2f4c7d9a1e0
5+
Create Date: 2026-06-19 00:00:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
13+
14+
revision: str = "c7a1b9e5d4f2"
15+
down_revision: Union[str, Sequence[str], None] = "b2f4c7d9a1e0"
16+
branch_labels: Union[str, Sequence[str], None] = None
17+
depends_on: Union[str, Sequence[str], None] = None
18+
19+
20+
def _rename_column(table_name: str, old_name: str, new_name: str) -> None:
21+
with op.batch_alter_table(table_name) as batch_op:
22+
batch_op.alter_column(old_name, new_column_name=new_name)
23+
24+
25+
def upgrade() -> None:
26+
"""Upgrade schema."""
27+
_rename_column("permissions", "descpription", "description")
28+
_rename_column("roles", "descpription", "description")
29+
30+
31+
def downgrade() -> None:
32+
"""Downgrade schema."""
33+
_rename_column("roles", "description", "descpription")
34+
_rename_column("permissions", "description", "descpription")
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""add authorization resources
2+
3+
Revision ID: d9a7c3f2b6e1
4+
Revises: c7a1b9e5d4f2
5+
Create Date: 2026-06-19 00:00:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
from uuid import uuid4
11+
12+
from alembic import op
13+
import sqlalchemy as sa
14+
15+
16+
revision: str = "d9a7c3f2b6e1"
17+
down_revision: Union[str, Sequence[str], None] = "c7a1b9e5d4f2"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
"""Upgrade schema."""
24+
op.create_table(
25+
"authorization_resources",
26+
sa.Column("id", sa.Uuid(), nullable=False),
27+
sa.Column(
28+
"created_at",
29+
sa.DateTime(timezone=True),
30+
server_default=sa.text("now()"),
31+
nullable=False,
32+
),
33+
sa.Column(
34+
"updated_at",
35+
sa.DateTime(timezone=True),
36+
server_default=sa.text("now()"),
37+
nullable=False,
38+
),
39+
sa.Column("deleted_at", sa.DateTime(), nullable=True),
40+
sa.Column("key", sa.String(length=100), nullable=False),
41+
sa.Column("name", sa.String(length=150), nullable=False),
42+
sa.Column("description", sa.String(length=255), nullable=True),
43+
sa.PrimaryKeyConstraint("id"),
44+
)
45+
op.create_index(
46+
op.f("ix_authorization_resources_key"),
47+
"authorization_resources",
48+
["key"],
49+
unique=True,
50+
)
51+
52+
with op.batch_alter_table("permissions") as batch_op:
53+
batch_op.add_column(sa.Column("resource_id", sa.Uuid(), nullable=True))
54+
batch_op.create_index(
55+
op.f("ix_permissions_resource_id"),
56+
["resource_id"],
57+
unique=False,
58+
)
59+
60+
bind = op.get_bind()
61+
resources = [
62+
row[0]
63+
for row in bind.execute(
64+
sa.text("select distinct resource from permissions where resource is not null")
65+
)
66+
]
67+
68+
resource_ids = {}
69+
for resource in resources:
70+
resource_id = uuid4()
71+
resource_ids[resource] = resource_id
72+
bind.execute(
73+
sa.text(
74+
"""
75+
insert into authorization_resources
76+
(id, key, name, description)
77+
values
78+
(:id, :key, :name, :description)
79+
"""
80+
),
81+
{
82+
"id": resource_id,
83+
"key": resource,
84+
"name": resource.replace("_", " ").title(),
85+
"description": f"{resource} resources",
86+
},
87+
)
88+
89+
for resource, resource_id in resource_ids.items():
90+
bind.execute(
91+
sa.text(
92+
"""
93+
update permissions
94+
set resource_id = :resource_id
95+
where resource = :resource
96+
"""
97+
),
98+
{"resource_id": resource_id, "resource": resource},
99+
)
100+
101+
with op.batch_alter_table("permissions") as batch_op:
102+
batch_op.create_foreign_key(
103+
"fk_permissions_resource_id_authorization_resources",
104+
"authorization_resources",
105+
["resource_id"],
106+
["id"],
107+
)
108+
109+
110+
def downgrade() -> None:
111+
"""Downgrade schema."""
112+
with op.batch_alter_table("permissions") as batch_op:
113+
batch_op.drop_constraint(
114+
"fk_permissions_resource_id_authorization_resources",
115+
type_="foreignkey",
116+
)
117+
batch_op.drop_index(op.f("ix_permissions_resource_id"))
118+
batch_op.drop_column("resource_id")
119+
120+
op.drop_index(
121+
op.f("ix_authorization_resources_key"),
122+
table_name="authorization_resources",
123+
)
124+
op.drop_table("authorization_resources")

scripts/seed.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import asyncio
2+
3+
from src.core.seed.runner import run_seeders
4+
5+
6+
async def main() -> None:
7+
result = await run_seeders()
8+
authorization = result.authorization
9+
print(
10+
"[seed:authorization] "
11+
f"resources_created={authorization.resources_created} "
12+
f"roles_created={authorization.roles_created} "
13+
f"permissions_created={authorization.permissions_created} "
14+
f"role_permissions_created={authorization.role_permissions_created} "
15+
f"policies_created={authorization.policies_created}"
16+
)
17+
user = result.user
18+
print(
19+
"[seed:user] "
20+
f"users_created={user.users_created} "
21+
f"roles_assigned={user.roles_assigned}"
22+
)
23+
24+
25+
if __name__ == "__main__":
26+
asyncio.run(main())

src/core/authorization/infrastructure/models/permission_model.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from sqlalchemy import String, UniqueConstraint
1+
from uuid import UUID
2+
3+
from sqlalchemy import ForeignKey, String, UniqueConstraint
24
from sqlalchemy.orm import Mapped, mapped_column
35

46
from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin
@@ -12,6 +14,10 @@ class PermissionModel(Base, TimeStampMixin, SoftDeleteMixin):
1214
)
1315

1416
key: Mapped[str] = mapped_column(String(255), unique=True, index=True)
17+
resource_id: Mapped[UUID] = mapped_column(
18+
ForeignKey("authorization_resources.id"),
19+
index=True,
20+
)
1521
resource: Mapped[str] = mapped_column(String(100), index=True)
1622
action: Mapped[str] = mapped_column(String(100), index=True)
17-
descpription: Mapped[str] = mapped_column(String(255), nullable=True)
23+
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from sqlalchemy import String
2+
from sqlalchemy.orm import Mapped, mapped_column
3+
4+
from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin
5+
from src.shared.database.model import Base
6+
7+
8+
class AuthorizationResourceModel(Base, TimeStampMixin, SoftDeleteMixin):
9+
__tablename__ = "authorization_resources"
10+
11+
key: Mapped[str] = mapped_column(String(100), unique=True, index=True)
12+
name: Mapped[str] = mapped_column(String(150), nullable=False)
13+
description: Mapped[str | None] = mapped_column(String(255), nullable=True)

src/core/authorization/infrastructure/models/role_model.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ class RoleModel(Base, TimeStampMixin, SoftDeleteMixin):
99
__tablename__ = "roles"
1010

1111
name: Mapped[str] = mapped_column(String(100), unique=True, index=True)
12-
descpription: Mapped[str] = mapped_column(String(255), nullable=True)
12+
description: Mapped[str | None] = mapped_column(String(255), nullable=True)

0 commit comments

Comments
 (0)