diff --git a/backend/openapi-schema.yml b/backend/openapi-schema.yml index 6fb162a2..d3456c8c 100644 --- a/backend/openapi-schema.yml +++ b/backend/openapi-schema.yml @@ -189,6 +189,25 @@ components: - password title: CreateUser type: object + CreateWorker: + properties: + name: + title: Name + type: string + required: + - name + title: CreateWorker + type: object + DeactivateWorker: + properties: + id: + format: uuid + title: Id + type: string + required: + - id + title: DeactivateWorker + type: object Document: properties: changed_at: @@ -561,6 +580,52 @@ components: - type title: ValidationError type: object + Worker: + properties: + deactivated_at: + format: date-time + title: Deactivated At + type: string + id: + format: uuid + title: Id + type: string + last_seen: + format: date-time + title: Last Seen + type: string + name: + title: Name + type: string + token: + title: Token + type: string + required: + - name + - token + title: Worker + type: object + WorkerWithId: + properties: + deactivated_at: + format: date-time + title: Deactivated At + type: string + id: + format: uuid + title: Id + type: string + last_seen: + format: date-time + title: Last Seen + type: string + name: + title: Name + type: string + required: + - name + title: WorkerWithId + type: object info: title: FastAPI version: 0.1.0 @@ -1394,6 +1459,92 @@ paths: $ref: '#/components/schemas/HTTPValidationError' description: Validation Error summary: Read User + /api/v1/worker/: + get: + operationId: list_workers_api_v1_worker__get + parameters: + - in: header + name: Api-Token + required: true + schema: + title: Api-Token + type: string + responses: + '200': + content: + application/json: + schema: + items: + $ref: '#/components/schemas/WorkerWithId' + title: Response List Workers Api V1 Worker Get + type: array + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: List Workers + /api/v1/worker/create/: + post: + operationId: create_worker_endpoint_api_v1_worker_create__post + parameters: + - in: header + name: Api-Token + required: true + schema: + title: Api-Token + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateWorker' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Worker' + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Create Worker Endpoint + /api/v1/worker/deactivate/: + post: + operationId: deactivate_worker_endpoint_api_v1_worker_deactivate__post + parameters: + - in: header + name: Api-Token + required: true + schema: + title: Api-Token + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DeactivateWorker' + required: true + responses: + '200': + content: + application/json: + schema: {} + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Deactivate Worker Endpoint /media/{file}: get: operationId: serve_media_media__file__get diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 90949755..527c8ca7 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -52,6 +52,7 @@ migrate = "alembic upgrade head" makemigrations = "alembic revision --autogenerate -m" create_user = "scripts/create_user.py" create_worker = "scripts/create_worker.py" +create_api_token = "scripts/create_api_token.py" reset_task = "scripts/reset_task.py" generate_openapi = "python -m scripts.generate_openapi" test = "pytest tests/" diff --git a/backend/scripts/create_api_token.py b/backend/scripts/create_api_token.py new file mode 100644 index 00000000..98b602a9 --- /dev/null +++ b/backend/scripts/create_api_token.py @@ -0,0 +1,12 @@ +import argparse + +from transcribee_backend.auth import create_api_token +from transcribee_backend.db import SessionContextManager + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--name", required=True) + args = parser.parse_args() + with SessionContextManager() as session: + token = create_api_token(session=session, name=args.name) + print(f"Token created: {token.token}") diff --git a/backend/transcribee_backend/auth.py b/backend/transcribee_backend/auth.py index 9ca7fc11..d352acd3 100644 --- a/backend/transcribee_backend/auth.py +++ b/backend/transcribee_backend/auth.py @@ -13,7 +13,14 @@ from transcribee_backend.db import get_session from transcribee_backend.exceptions import UserAlreadyExists, UserDoesNotExist from transcribee_backend.helpers.time import now_tz_aware -from transcribee_backend.models import DocumentShareToken, Task, User, UserToken, Worker +from transcribee_backend.models import ( + ApiToken, + DocumentShareToken, + Task, + User, + UserToken, + Worker, +) class NotAuthorized(Exception): @@ -104,7 +111,9 @@ def validate_worker_authorization(session: Session, authorization: str) -> Worke if token_type != "Worker": raise HTTPException(status_code=401) - statement = select(Worker).where(Worker.token == token) + statement = select(Worker).where( + Worker.token == token, col(Worker.deactivated_at).is_(None) + ) worker = session.exec(statement).one_or_none() if worker is None: raise HTTPException(status_code=401) @@ -194,3 +203,37 @@ def validate_share_authorization( return token raise HTTPException(status_code=401) + + +def get_api_token( + session: Session = Depends(get_session), + api_token: str = Header(alias="Api-Token"), +) -> Optional[ApiToken]: + return validate_api_token_authorization(session, api_token) + + +def validate_api_token_authorization(session: Session, api_token: str): + statement = select(ApiToken).where( + ApiToken.token == api_token, + ) + token = session.exec(statement).one_or_none() + if token: + return token + + raise HTTPException(status_code=401) + + +def create_worker(session: Session, name: str) -> Worker: + token = b64encode(os.urandom(32)).decode() + worker = Worker(name=name, token=token, last_seen=None, deactivated_at=None) + session.add(worker) + session.commit() + return worker + + +def create_api_token(session: Session, name: str) -> ApiToken: + token = b64encode(os.urandom(64)).decode() + token = ApiToken(name=name, token=token) + session.add(token) + session.commit() + return token diff --git a/backend/transcribee_backend/db/migrations/versions/417eece003cb_add_worker_deactivated_at_flag.py b/backend/transcribee_backend/db/migrations/versions/417eece003cb_add_worker_deactivated_at_flag.py new file mode 100644 index 00000000..31485c68 --- /dev/null +++ b/backend/transcribee_backend/db/migrations/versions/417eece003cb_add_worker_deactivated_at_flag.py @@ -0,0 +1,33 @@ +"""Add Worker.deactivated_at flag + +Revision ID: 417eece003cb +Revises: 937846561faf +Create Date: 2023-11-18 17:14:05.221788 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "417eece003cb" +down_revision = "937846561faf" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("worker", schema=None) as batch_op: + batch_op.add_column( + sa.Column("deactivated_at", sa.DateTime(timezone=True), nullable=True) + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("worker", schema=None) as batch_op: + batch_op.drop_column("deactivated_at") + + # ### end Alembic commands ### diff --git a/backend/transcribee_backend/db/migrations/versions/937846561faf_add_apitoken.py b/backend/transcribee_backend/db/migrations/versions/937846561faf_add_apitoken.py new file mode 100644 index 00000000..8428a865 --- /dev/null +++ b/backend/transcribee_backend/db/migrations/versions/937846561faf_add_apitoken.py @@ -0,0 +1,40 @@ +"""Add ApiToken + +Revision ID: 937846561faf +Revises: d679c226343d +Create Date: 2023-11-16 19:42:31.560991 + +""" +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from alembic import op + +# revision identifiers, used by Alembic. +revision = "937846561faf" +down_revision = "d679c226343d" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "apitoken", + sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("apitoken", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_apitoken_id"), ["id"], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("apitoken", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_apitoken_id")) + + op.drop_table("apitoken") + # ### end Alembic commands ### diff --git a/backend/transcribee_backend/main.py b/backend/transcribee_backend/main.py index 3943fb42..53712bf8 100644 --- a/backend/transcribee_backend/main.py +++ b/backend/transcribee_backend/main.py @@ -11,6 +11,7 @@ from transcribee_backend.routers.page import page_router from transcribee_backend.routers.task import task_router from transcribee_backend.routers.user import user_router +from transcribee_backend.routers.worker import worker_router from .media_storage import serve_media @@ -32,6 +33,7 @@ app.include_router(task_router, prefix="/api/v1/tasks") app.include_router(config_router, prefix="/api/v1/config") app.include_router(page_router, prefix="/api/v1/page") +app.include_router(worker_router, prefix="/api/v1/worker") @app.get("/") diff --git a/backend/transcribee_backend/models/__init__.py b/backend/transcribee_backend/models/__init__.py index 8ab16e5c..21fbc419 100644 --- a/backend/transcribee_backend/models/__init__.py +++ b/backend/transcribee_backend/models/__init__.py @@ -1,3 +1,4 @@ +from .api import ApiToken from .document import ( Document, DocumentMediaFile, @@ -19,6 +20,7 @@ from .worker import Worker __all__ = [ + "ApiToken", "Document", "DocumentMediaFile", "DocumentMediaTag", diff --git a/backend/transcribee_backend/models/api.py b/backend/transcribee_backend/models/api.py new file mode 100644 index 00000000..72b573a7 --- /dev/null +++ b/backend/transcribee_backend/models/api.py @@ -0,0 +1,18 @@ +import uuid + +from sqlmodel import Field, SQLModel + + +class ApiTokenBase(SQLModel): + id: uuid.UUID + name: str + + +class ApiToken(ApiTokenBase, table=True): + id: uuid.UUID = Field( + default_factory=uuid.uuid4, + primary_key=True, + index=True, + nullable=False, + ) + token: str diff --git a/backend/transcribee_backend/models/worker.py b/backend/transcribee_backend/models/worker.py index c9b5c191..1dce1eb3 100644 --- a/backend/transcribee_backend/models/worker.py +++ b/backend/transcribee_backend/models/worker.py @@ -11,13 +11,19 @@ class WorkerBase(SQLModel): last_seen: Optional[datetime.datetime] = Field( sa_column=Column(DateTime(timezone=True), nullable=True) ) + deactivated_at: Optional[datetime.datetime] = Field( + sa_column=Column(DateTime(timezone=True), nullable=True) + ) -class Worker(WorkerBase, table=True): +class WorkerWithId(WorkerBase): id: uuid.UUID = Field( default_factory=uuid.uuid4, primary_key=True, index=True, nullable=False, ) + + +class Worker(WorkerWithId, table=True): token: str diff --git a/backend/transcribee_backend/routers/worker.py b/backend/transcribee_backend/routers/worker.py new file mode 100644 index 00000000..0a3af3ca --- /dev/null +++ b/backend/transcribee_backend/routers/worker.py @@ -0,0 +1,66 @@ +import uuid +from typing import List + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, col, select +from sqlmodel.main import SQLModel + +from transcribee_backend.auth import create_worker, get_api_token +from transcribee_backend.db import get_session +from transcribee_backend.helpers.time import now_tz_aware +from transcribee_backend.models import ApiToken +from transcribee_backend.models.worker import Worker, WorkerWithId + +worker_router = APIRouter() + + +class CreateWorker(SQLModel): + name: str + + +class DeactivateWorker(SQLModel): + id: uuid.UUID + + +@worker_router.post("/create/") +def create_worker_endpoint( + worker: CreateWorker, + session: Session = Depends(get_session), + _token: ApiToken = Depends(get_api_token), +) -> Worker: + return create_worker(session=session, name=worker.name) + + +@worker_router.get("/") +def list_workers( + session: Session = Depends(get_session), + _token: ApiToken = Depends(get_api_token), +) -> List[WorkerWithId]: + statement = select(Worker).where(col(Worker.deactivated_at).is_(None)) + return [ + WorkerWithId( + name=worker.name, + last_seen=worker.last_seen, + id=worker.id, + deactivated_at=worker.deactivated_at, + ) + for worker in session.exec(statement).all() + ] + + +@worker_router.post("/deactivate/") +def deactivate_worker_endpoint( + body: DeactivateWorker, + session: Session = Depends(get_session), + _token: ApiToken = Depends(get_api_token), +) -> None: + query = select(Worker).where( + Worker.id == body.id, col(Worker.deactivated_at).is_(None) + ) + result = session.exec(query).one_or_none() + if result is None: + raise HTTPException(status_code=404, detail="Worker not found.") + + result.deactivated_at = now_tz_aware() + session.add(result) + session.commit() diff --git a/frontend/src/openapi-schema.ts b/frontend/src/openapi-schema.ts index ca8006dd..71cdac75 100644 --- a/frontend/src/openapi-schema.ts +++ b/frontend/src/openapi-schema.ts @@ -103,6 +103,18 @@ export interface paths { /** Read User */ get: operations["read_user_api_v1_users_me__get"]; }; + "/api/v1/worker/": { + /** List Workers */ + get: operations["list_workers_api_v1_worker__get"]; + }; + "/api/v1/worker/create/": { + /** Create Worker Endpoint */ + post: operations["create_worker_endpoint_api_v1_worker_create__post"]; + }; + "/api/v1/worker/deactivate/": { + /** Deactivate Worker Endpoint */ + post: operations["deactivate_worker_endpoint_api_v1_worker_deactivate__post"]; + }; "/media/{file}": { /** Serve Media */ get: operations["serve_media_media__file__get"]; @@ -227,6 +239,19 @@ export interface components { /** Username */ username: string; }; + /** CreateWorker */ + CreateWorker: { + /** Name */ + name: string; + }; + /** DeactivateWorker */ + DeactivateWorker: { + /** + * Id + * Format: uuid + */ + id: string; + }; /** Document */ Document: { /** Changed At */ @@ -450,6 +475,48 @@ export interface components { /** Error Type */ type: string; }; + /** Worker */ + Worker: { + /** + * Deactivated At + * Format: date-time + */ + deactivated_at?: string; + /** + * Id + * Format: uuid + */ + id?: string; + /** + * Last Seen + * Format: date-time + */ + last_seen?: string; + /** Name */ + name: string; + /** Token */ + token: string; + }; + /** WorkerWithId */ + WorkerWithId: { + /** + * Deactivated At + * Format: date-time + */ + deactivated_at?: string; + /** + * Id + * Format: uuid + */ + id?: string; + /** + * Last Seen + * Format: date-time + */ + last_seen?: string; + /** Name */ + name: string; + }; }; responses: never; parameters: never; @@ -1133,6 +1200,82 @@ export interface operations { }; }; }; + /** List Workers */ + list_workers_api_v1_worker__get: { + parameters: { + header: { + "Api-Token": string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["WorkerWithId"][]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + /** Create Worker Endpoint */ + create_worker_endpoint_api_v1_worker_create__post: { + parameters: { + header: { + "Api-Token": string; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateWorker"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["Worker"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + /** Deactivate Worker Endpoint */ + deactivate_worker_endpoint_api_v1_worker_deactivate__post: { + parameters: { + header: { + "Api-Token": string; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DeactivateWorker"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; /** Serve Media */ serve_media_media__file__get: { parameters: {