Skip to content

Commit 6f0ba7b

Browse files
authored
Merge pull request #65 from ScilifelabDataCentre/frontend-cli-annoucements
SQ-821: Announcements to users via CLI and webpage
2 parents dd1755f + 113b7be commit 6f0ba7b

17 files changed

Lines changed: 454 additions & 27 deletions

File tree

docker/divbase_compose.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ services:
162162
- ../packages/divbase-api/src/divbase_api/migrations:/app/migrations # this is to get the migration scripts which are generated inside the container
163163
- ../packages/divbase-api/src/divbase_api/alembic.ini:/app/alembic.ini
164164
healthcheck:
165-
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
165+
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/core/health"]
166166
interval: 5s
167167
timeout: 5s
168168
retries: 5

docs/development/database-migrations.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ op.execute("DROP TYPE IF EXISTS projectroles")
6565

6666
See the bottom of the first migration script (2025-12-04_initial_migration.py) for an example of this.
6767

68+
You could also run the automatic migration tests now to help verify your migrations scripts are correct.
69+
70+
```bash
71+
pytest -s tests/migrations
72+
```
73+
6874
#### 4. Test out the migration
6975

7076
- In local development, migrations are applied automatically when the stack starts up using the `db-migrator` init-container.

packages/divbase-api/src/divbase_api/admin_panel.py

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
BooleanField,
2323
DateTimeField,
2424
EmailField,
25+
EnumField,
2526
HasOne,
2627
IntegerField,
2728
JSONField,
@@ -36,12 +37,13 @@
3637
from divbase_api.db import get_db
3738
from divbase_api.deps import _authenticate_frontend_user_from_tokens
3839
from divbase_api.frontend_routes.auth import get_login, post_logout
40+
from divbase_api.models.announcements import AnnouncementDB, AnnouncementLevel, AnnouncementTarget
3941
from divbase_api.models.project_versions import ProjectVersionDB
40-
from divbase_api.models.projects import ProjectDB, ProjectMembershipDB
41-
from divbase_api.models.revoked_tokens import RevokedTokenDB
42+
from divbase_api.models.projects import ProjectDB, ProjectMembershipDB, ProjectRoles
43+
from divbase_api.models.revoked_tokens import RevokedTokenDB, TokenRevokeReason
4244
from divbase_api.models.task_history import CeleryTaskMeta, TaskHistoryDB, TaskStartedAtDB
4345
from divbase_api.models.users import UserDB
44-
from divbase_api.security import get_password_hash
46+
from divbase_api.security import TokenType, get_password_hash
4547

4648
logger = logging.getLogger(__name__)
4749

@@ -275,7 +277,7 @@ class ProjectMembershipView(ModelView):
275277
IntegerField("id", label="ID", disabled=True),
276278
HasOne("user", identity="user", label="User"),
277279
HasOne("project", identity="project", label="Project"),
278-
StringField("role", label="Role", required=True),
280+
EnumField("role", label="Role", required=True, enum=ProjectRoles),
279281
DateTimeField("created_at", label="Created At", disabled=True),
280282
DateTimeField("updated_at", label="Updated At", disabled=True),
281283
]
@@ -377,9 +379,9 @@ class RevokedTokenView(ModelView):
377379
fields = [
378380
IntegerField("id", label="ID", disabled=True),
379381
StringField("token_jti", label="Token JTI", required=True),
380-
StringField("token_type", label="Token Type", required=True),
382+
EnumField("token_type", label="Token Type", required=True, enum=TokenType),
381383
DateTimeField("revoked_at", label="Revoked At", disabled=True),
382-
StringField("revoked_reason", label="Revoked Reason", required=True),
384+
EnumField("revoked_reason", label="Revoke Reason", required=True, enum=TokenRevokeReason),
383385
IntegerField("user_id", label="User ID", required=False),
384386
HasOne("user", identity="user", label="User"),
385387
DateTimeField("created_at", label="Created At", disabled=True),
@@ -567,6 +569,37 @@ def can_delete(self, request: Request) -> bool:
567569
return False
568570

569571

572+
class AnnouncementView(ModelView):
573+
"""
574+
Custom admin panel View for the AnnouncementDB model.
575+
"""
576+
577+
page_size_options = PAGINATION_DEFAULTS
578+
fields = [
579+
IntegerField("id", label="ID", disabled=True),
580+
StringField("heading", label="Heading", required=True),
581+
TextAreaField("message", label="Message", required=False),
582+
EnumField("target", label="Target", required=True, enum=AnnouncementTarget),
583+
EnumField("level", label="Level", required=True, enum=AnnouncementLevel),
584+
DateTimeField(
585+
"auto_expire_at",
586+
help_text="Optional timestamp when the announcement will auto expire. After this timestamp the announcement will no longer be displayed on the frontend or CLI. Value can be left empty.",
587+
),
588+
DateTimeField(
589+
"created_at", help_text="Timestamp when the entry was created. Value determined by system.", disabled=True
590+
),
591+
DateTimeField(
592+
"updated_at",
593+
help_text="Timestamp when the entry was last updated. Value determined by system.",
594+
disabled=True,
595+
),
596+
]
597+
598+
exclude_fields_from_list = ["message"]
599+
exclude_fields_from_edit = ["id", "created_at", "updated_at"]
600+
exclude_fields_from_detail = []
601+
602+
570603
class DivBaseAuthProvider(AuthProvider):
571604
"""
572605
This class enables starlette-admin to make use of DivBase's pre-existing auth system.
@@ -646,6 +679,8 @@ def register_admin_panel(app: FastAPI, engine: AsyncEngine) -> None:
646679
admin.add_view(
647680
CeleryTaskMetaView(CeleryTaskMeta, icon="fas fa-tasks", label="Celery Task Meta", identity="celery-meta")
648681
)
649-
650682
admin.add_view(TaskStartedAtView(TaskStartedAtDB, icon="fas fa-clock", label="Task Started At"))
683+
admin.add_view(
684+
AnnouncementView(AnnouncementDB, icon="fas fa-bullhorn", label="Announcements", identity="announcement")
685+
)
651686
admin.mount_to(app)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""
2+
Crud operations on the announcements table,
3+
which stores announcements that can be displayed to users on the frontend and the cli.
4+
5+
Starlette admin will manage the creation/editing/deletion of announcements,
6+
so this module only covers retrieving active announcements to be displayed on frontend or by CLI.
7+
"""
8+
9+
from datetime import datetime, timezone
10+
11+
from sqlalchemy import select
12+
from sqlalchemy.ext.asyncio import AsyncSession
13+
14+
from divbase_api.models.announcements import AnnouncementDB, AnnouncementTarget
15+
from divbase_lib.api_schemas.announcements import AnnouncementResponse
16+
17+
18+
async def get_active_announcements(db: AsyncSession, target: AnnouncementTarget) -> list[AnnouncementResponse]:
19+
"""Get active announcements for a given target (cli, web or both)."""
20+
21+
if target == AnnouncementTarget.BOTH:
22+
raise ValueError("Target cannot be both when retrieving announcements. Please specify either cli or web.")
23+
24+
stmt = (
25+
select(AnnouncementDB)
26+
.where((AnnouncementDB.target == target) | (AnnouncementDB.target == AnnouncementTarget.BOTH))
27+
.where((AnnouncementDB.auto_expire_at.is_(None)) | (AnnouncementDB.auto_expire_at > datetime.now(timezone.utc)))
28+
)
29+
result = await db.execute(stmt)
30+
announcements = result.scalars().all()
31+
return [AnnouncementResponse.model_validate(announcement) for announcement in announcements]

packages/divbase-api/src/divbase_api/divbase_api.py

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from divbase_api.frontend_routes.projects import fr_projects_router
2828
from divbase_api.routes.admin import admin_router
2929
from divbase_api.routes.auth import auth_router
30+
from divbase_api.routes.core import core_router
3031
from divbase_api.routes.project_versions import project_version_router
3132
from divbase_api.routes.queries import query_router
3233
from divbase_api.routes.s3 import s3_router
@@ -66,23 +67,20 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
6667

6768
app = FastAPI(lifespan=lifespan, title="DivBase API", docs_url="/api/v1/docs")
6869

69-
7070
app.include_router(auth_router, prefix="/api/v1/auth", tags=["auth"])
7171
app.include_router(admin_router, prefix="/api/v1/admin", tags=["admin"])
72-
app.include_router(s3_router, prefix="/api/v1/s3", tags=["s3"])
72+
app.include_router(core_router, prefix="/api/v1/core", tags=["core"])
7373
app.include_router(project_version_router, prefix="/api/v1/project-versions", tags=["project-versioning"])
74-
74+
app.include_router(query_router, prefix="/api/v1/query", tags=["query"])
75+
app.include_router(s3_router, prefix="/api/v1/s3", tags=["s3"])
76+
app.include_router(task_history_router, prefix="/api/v1/task-history", tags=["task-history"])
77+
app.include_router(vcf_dimensions_router, prefix="/api/v1/vcf-dimensions", tags=["vcf-dimensions"])
7578

7679
app.include_router(fr_auth_router, prefix="", include_in_schema=False)
7780
app.include_router(fr_core_router, prefix="", include_in_schema=False)
7881
app.include_router(fr_profile_router, prefix="/profile", include_in_schema=False)
7982
app.include_router(fr_projects_router, prefix="/projects", include_in_schema=False)
8083

81-
app.include_router(query_router, prefix="/api/v1/query", tags=["query"])
82-
83-
app.include_router(vcf_dimensions_router, prefix="/api/v1/vcf-dimensions", tags=["vcf-dimensions"])
84-
85-
app.include_router(task_history_router, prefix="/api/v1/task-history", tags=["task-history"])
8684

8785
register_exception_handlers(app)
8886
register_admin_panel(app=app, engine=engine)
@@ -91,12 +89,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
9189
app.mount("/static", StaticFiles(directory=static_dir_path), name="static")
9290

9391

94-
# TODO - move below routes into routes dir when ready.
95-
@app.get("/api/health")
96-
def health():
97-
return {"status": "ok"}
98-
99-
10092
def main():
10193
uvicorn.run("divbase_api.divbase_api:app", host="127.0.0.1", port=8000, reload=True)
10294

packages/divbase-api/src/divbase_api/frontend_routes/core.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@
1010
from fastapi import APIRouter, Depends, Request
1111
from fastapi.responses import HTMLResponse
1212
from fastapi.templating import Jinja2Templates
13+
from sqlalchemy.ext.asyncio import AsyncSession
1314

1415
from divbase_api.api_config import settings
16+
from divbase_api.crud.announcements import get_active_announcements
17+
from divbase_api.db import get_db
1518
from divbase_api.deps import get_current_user_from_cookie_optional
19+
from divbase_api.models.announcements import AnnouncementTarget
1620
from divbase_api.models.users import UserDB
1721

1822
fr_core_router = APIRouter()
@@ -23,10 +27,15 @@
2327

2428

2529
@fr_core_router.get("/", response_class=HTMLResponse)
26-
async def get_home_page(request: Request, current_user: UserDB | None = Depends(get_current_user_from_cookie_optional)):
30+
async def get_home_page(
31+
request: Request,
32+
db: AsyncSession = Depends(get_db),
33+
current_user: UserDB | None = Depends(get_current_user_from_cookie_optional),
34+
):
2735
"""Render the home page."""
36+
announcements = await get_active_announcements(db=db, target=AnnouncementTarget.WEB)
2837
return templates.TemplateResponse(
2938
request=request,
3039
name="index.html",
31-
context={"request": request, "current_user": current_user},
40+
context={"request": request, "current_user": current_user, "announcements": announcements},
3241
)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""add announcements db
2+
3+
Revision ID: 2ec979fc9dbb
4+
Revises: 3e168ddc857e
5+
Create Date: 2026-02-09 09:43:52.822149
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = "2ec979fc9dbb"
16+
down_revision: Union[str, Sequence[str], None] = "3e168ddc857e"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.create_table(
25+
"announcement",
26+
sa.Column("heading", sa.String(length=200), nullable=False),
27+
sa.Column("message", sa.String(length=1000), nullable=True),
28+
sa.Column("target", sa.Enum("BOTH", "CLI", "WEB", name="announcementtarget"), nullable=False),
29+
sa.Column("level", sa.Enum("INFO", "SUCCESS", "WARNING", "DANGER", name="announcementlevel"), nullable=False),
30+
sa.Column("auto_expire_at", sa.DateTime(timezone=True), nullable=True),
31+
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
32+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
33+
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
34+
sa.PrimaryKeyConstraint("id"),
35+
)
36+
op.create_index(op.f("ix_announcement_heading"), "announcement", ["heading"], unique=False)
37+
op.create_index(op.f("ix_announcement_id"), "announcement", ["id"], unique=False)
38+
op.create_index(op.f("ix_announcement_level"), "announcement", ["level"], unique=False)
39+
op.create_index(op.f("ix_announcement_target"), "announcement", ["target"], unique=False)
40+
# ### end Alembic commands ###
41+
42+
43+
def downgrade() -> None:
44+
"""Downgrade schema."""
45+
# ### commands auto generated by Alembic - please adjust! ###
46+
op.drop_index(op.f("ix_announcement_target"), table_name="announcement")
47+
op.drop_index(op.f("ix_announcement_level"), table_name="announcement")
48+
op.drop_index(op.f("ix_announcement_id"), table_name="announcement")
49+
op.drop_index(op.f("ix_announcement_heading"), table_name="announcement")
50+
op.drop_table("announcement")
51+
# ### end Alembic commands ###
52+
53+
# Human added, explicitly drop enums
54+
# see https://github.com/sqlalchemy/alembic/issues/886
55+
op.execute("DROP TYPE IF EXISTS announcementtarget")
56+
op.execute("DROP TYPE IF EXISTS announcementlevel")

packages/divbase-api/src/divbase_api/models/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
They are imported in dependency order to avoid circular import issues.
55
"""
66

7+
from divbase_api.models.announcements import AnnouncementDB, AnnouncementTarget
78
from divbase_api.models.base import Base, BaseDBModel
89
from divbase_api.models.project_versions import ProjectVersionDB
910
from divbase_api.models.projects import ProjectDB, ProjectMembershipDB, ProjectRoles
@@ -26,4 +27,6 @@
2627
"TokenRevokeReason",
2728
"RevokedTokenDB",
2829
"ProjectVersionDB",
30+
"AnnouncementDB",
31+
"AnnouncementTarget",
2932
]
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""
2+
Announcements DB Model.
3+
4+
Stores announcements that can be displayed to users on the frontend (banner) or via the CLI.
5+
"""
6+
7+
from datetime import datetime
8+
from enum import StrEnum
9+
10+
from sqlalchemy import DateTime, Enum, String
11+
from sqlalchemy.orm import Mapped, mapped_column
12+
13+
from divbase_api.models.base import BaseDBModel
14+
15+
16+
class AnnouncementTarget(StrEnum):
17+
"""Possible targets for announcements."""
18+
19+
BOTH = "both"
20+
CLI = "cli"
21+
WEB = "web"
22+
23+
24+
class AnnouncementLevel(StrEnum):
25+
"""
26+
Possible levels for announcements.
27+
These match bootstrap alert levels, so this will control the announcement styling on the frontend.
28+
"""
29+
30+
INFO = "info"
31+
SUCCESS = "success"
32+
WARNING = "warning"
33+
DANGER = "danger"
34+
35+
36+
class AnnouncementDB(BaseDBModel):
37+
"""
38+
DB Model for an announcement.
39+
40+
id, created_at and updated_at are inherited from BaseDBModel.
41+
"""
42+
43+
__tablename__ = "announcement"
44+
45+
heading: Mapped[str] = mapped_column(String(200), index=True)
46+
message: Mapped[str | None] = mapped_column(String(1000), nullable=True)
47+
target: Mapped[AnnouncementTarget] = mapped_column(Enum(AnnouncementTarget), index=True)
48+
level: Mapped[AnnouncementLevel] = mapped_column(Enum(AnnouncementLevel), index=True)
49+
auto_expire_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
50+
51+
def __repr__(self) -> str:
52+
return f"<AnnouncementDB id={self.id}, heading={self.heading}, target={self.target}, level={self.level}>"
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""
2+
Core API routes for divbase, including health checks and announcements.
3+
4+
Note that unlike every other API route these routes are not behind authentication...
5+
"""
6+
7+
from fastapi import APIRouter, Depends, status
8+
from sqlalchemy.ext.asyncio import AsyncSession
9+
10+
from divbase_api.crud.announcements import get_active_announcements
11+
from divbase_api.db import get_db
12+
from divbase_api.models.announcements import AnnouncementTarget
13+
from divbase_lib.api_schemas.announcements import AnnouncementResponse
14+
15+
core_router = APIRouter()
16+
17+
18+
@core_router.get("/health", status_code=status.HTTP_200_OK, response_model=dict[str, str])
19+
def health():
20+
"""Basic health check endpoint for the server."""
21+
return {"status": "ok"}
22+
23+
24+
@core_router.get("/announcements", status_code=status.HTTP_200_OK, response_model=list[AnnouncementResponse])
25+
async def announcements(db: AsyncSession = Depends(get_db)):
26+
"""Returns active announcements for CLI users from the server."""
27+
return await get_active_announcements(db=db, target=AnnouncementTarget.CLI)

0 commit comments

Comments
 (0)