From 1c8474e50c74ec048fcabbd146a80c4bcb6ba423 Mon Sep 17 00:00:00 2001 From: Jonathan Villanueva Date: Thu, 24 Sep 2026 22:22:17 +0000 Subject: [PATCH 1/7] feat: devcontainer and add pre-commit rules feat: update Dockerfile to use Python 3.13, enhance VSCode settings, and add MongoDB extension feat: implement self-service registration and QR code generation for participants feat: implement self-service registration and QR code generation for participants feat: add presentation checkpoint link management and self-service participation features --- .devcontainer/devcontainer-lock.json | 14 + .devcontainer/devcontainer.json | 50 ++ .dockerignore | 24 +- .env.production | 2 +- .github/workflows/docker-image.yml | 72 +- .gitignore | 280 +++---- .pre-commit-config.yaml | 89 +++ .vscode/launch.json | 8 +- .vscode/settings.json | 3 - Dockerfile | 23 +- app/auth/auth.py | 203 +++-- app/core/configuration.py | 33 +- app/core/database.py | 36 +- app/core/roles.py | 45 +- app/main.py | 215 ++--- app/models/base.py | 19 +- app/models/buy.py | 45 +- app/models/combo.py | 99 +-- app/models/control.py | 49 +- app/models/gift.py | 91 ++- app/models/location.py | 41 +- app/models/meal.py | 46 +- app/models/participant.py | 138 ++-- app/models/point.py | 50 +- app/models/presentation.py | 68 +- app/models/presentation_checkpoint_link.py | 26 + app/models/product.py | 59 +- app/models/special_point.py | 74 +- app/models/token.py | 27 +- app/models/user.py | 50 +- app/routers/auth.py | 346 ++++---- app/routers/buy.py | 200 ++--- app/routers/combos.py | 398 +++++----- app/routers/control.py | 250 +++--- app/routers/gifts.py | 252 +++--- app/routers/locations.py | 118 +-- app/routers/meals.py | 215 ++--- app/routers/participants.py | 783 ++++++++++--------- app/routers/points.py | 380 +++++---- app/routers/presentation_checkpoint_links.py | 145 ++++ app/routers/presentations.py | 164 ++-- app/routers/products.py | 187 +++-- app/routers/self_service.py | 301 +++++++ app/routers/special_point.py | 216 ++--- app/services/buy.py | 126 +-- app/services/combo.py | 398 +++++----- app/services/control.py | 231 +++--- app/services/gift.py | 304 +++---- app/services/location.py | 89 +-- app/services/meal.py | 121 ++- app/services/participant.py | 546 ++++++------- app/services/point.py | 156 ++-- app/services/presentation.py | 127 +-- app/services/presentation_checkpoint_link.py | 183 +++++ app/services/product.py | 259 ++++-- app/services/redemption.py | 137 ++++ app/services/special_point.py | 161 ++-- app/services/user.py | 165 ++-- app/utils/control_code_generator.py | 164 ++++ app/utils/link_code_generator.py | 54 ++ app/utils/mailer.py | 31 + app/utils/mongo_validator.py | 34 +- app/utils/qr_generator.py | 69 ++ docker-compose.dev.yaml | 18 +- docker-compose.traefik.yaml | 38 +- requirements.in | 17 + requirements.txt | 135 +++- 67 files changed, 5677 insertions(+), 3820 deletions(-) create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 .devcontainer/devcontainer.json create mode 100644 .pre-commit-config.yaml delete mode 100644 .vscode/settings.json create mode 100644 app/models/presentation_checkpoint_link.py create mode 100644 app/routers/presentation_checkpoint_links.py create mode 100644 app/routers/self_service.py create mode 100644 app/services/presentation_checkpoint_link.py create mode 100644 app/services/redemption.py create mode 100644 app/utils/control_code_generator.py create mode 100644 app/utils/link_code_generator.py create mode 100644 app/utils/mailer.py create mode 100644 app/utils/qr_generator.py create mode 100644 requirements.in diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..c17ee8b --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,14 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "version": "2.6.0", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:836cd0dc8d0036367f7dbc63cbdae70f7d43f3819ca9daca637eeb1ca0d99611", + "integrity": "sha256:836cd0dc8d0036367f7dbc63cbdae70f7d43f3819ca9daca637eeb1ca0d99611" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "1.3.8", + "resolved": "ghcr.io/devcontainers/features/git@sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2", + "integrity": "sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..70b61a4 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,50 @@ +{ + "name": "Control API", + "build": { + "context": "..", + "dockerfile": "../Dockerfile" + }, + "features": { + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/common-utils:2": {} + }, + "postCreateCommand": "git config --global --add safe.directory /workspaces/${localWorkspaceFolderBasename} && pre-commit install", + "customizations": { + "vscode": { + "settings": { + "terminal.integrated.defaultProfile.linux": "zsh" + }, + "extensions": [ + "alefragnani.Bookmarks", + "ms-vscode-remote.remote-containers", + "hediet.vscode-drawio", + "usernamehw.errorlens", + "johnpapa.vscode-peacock", + "ms-vscode-remote.remote-ssh", + "ms-vscode.remote-server", + "ms-vscode-remote.vscode-remote-extensionpack", + "ms-vscode.remote-explorer", + "njpwerner.autodocstring", + "ms-python.autopep8", + "ms-python.black-formatter", + "streetsidesoftware.code-spell-checker", + "vivaxy.vscode-conventional-commits", + "mhutchie.git-graph", + "eamodio.gitlens", + "oderwat.indent-rainbow", + "ryu1kn.partial-diff", + "ms-python.vscode-pylance", + "ms-python.python", + "ms-python.debugpy", + "mechatroner.rainbow-csv", + "streetsidesoftware.code-spell-checker-spanish", + "jackiotyu.git-worktree-manager", + "aaron-bond.better-comments", + "rangav.vscode-thunder-client", + "christian-kohler.path-intellisense", + "charliermarsh.ruff", + "mongodb.mongodb-vscode" + ] + } + } +} diff --git a/.dockerignore b/.dockerignore index 6ea782a..1bb0f9d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,12 +1,12 @@ -Dockerfile -.dockerignore -.git -.github -.vscode -.*ignore -*.md -docker-compose*.yaml -.env -.env.production -venv -k8s \ No newline at end of file +Dockerfile +.dockerignore +.git +.github +.vscode +.*ignore +*.md +docker-compose*.yaml +.env +.env.production +venv +k8s diff --git a/.env.production b/.env.production index 76be4b0..bac414d 100644 --- a/.env.production +++ b/.env.production @@ -8,4 +8,4 @@ APP_USERS_API_URL=http://localhost/api/event//users APP_USERS_API_TOKEN=token TRAEFIK_ROUTE=service_name TRAEFIK_APP_DOMAIN=`dom1.example.com`,`dom2.example.com` -TRAEFIK_APP_PORT=8000 \ No newline at end of file +TRAEFIK_APP_PORT=8000 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 3c79cdb..a58b84b 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,36 +1,36 @@ -name: Docker Image CI - -on: - push: - branches: [master] - pull_request: - branches: [master] - -env: - IMAGE_TAG: ${{ github.sha }} - DOCKER_IMAGE: ${{ vars.DOCKER_IMAGE }} -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Login to GCR - uses: docker/login-action@v2 - with: - registry: gcr.io - username: _json_key - password: ${{ secrets.GCP_SA_KEY }} - - - name: Build and push - uses: docker/build-push-action@v3 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{env.DOCKER_IMAGE}}:${{env.IMAGE_TAG}},${{env.DOCKER_IMAGE}}:latest +name: Docker Image CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +env: + IMAGE_TAG: ${{ github.sha }} + DOCKER_IMAGE: ${{ vars.DOCKER_IMAGE }} +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to GCR + uses: docker/login-action@v2 + with: + registry: gcr.io + username: _json_key + password: ${{ secrets.GCP_SA_KEY }} + + - name: Build and push + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{env.DOCKER_IMAGE}}:${{env.IMAGE_TAG}},${{env.DOCKER_IMAGE}}:latest diff --git a/.gitignore b/.gitignore index 450fe53..2b05c5a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,140 +1,140 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ -*.mongodb.js -keys/* -!keys/README.md - -k8s/* -!k8s/deploy.yaml -!k8s/service.yaml -!k8s/ingress.yaml -!k8s/expose.yaml -!k8s/secret.yaml -!k8s/secret_service_account.yaml \ No newline at end of file +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ +*.mongodb.js +keys/* +!keys/README.md + +k8s/* +!k8s/deploy.yaml +!k8s/service.yaml +!k8s/ingress.yaml +!k8s/expose.yaml +!k8s/secret.yaml +!k8s/secret_service_account.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e1b6da2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,89 @@ +default_install_hook_types: + - pre-commit + - commit-msg +repos: + - repo: https://github.com/psf/black + rev: 26.5.1 + hooks: + - id: black + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: end-of-file-fixer + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v4.4.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.8 + hooks: + - id: ruff-check + name: Ruff isort + args: [--select=I, --fix] + - id: ruff-check + name: Ruff unused imports + args: [--select=F401, --fix] + - id: ruff-check + name: Ruff f-string without interpolation + args: [--select=F541, --fix] + - id: ruff-check + name: Ruff not in test comparison + args: [--select=E713, --fix] + - id: ruff-check + name: Ruff test object identity comparison + args: [--select=E714, --fix] + - id: ruff-check + name: Ruff do not compare types, use isinstance + args: [--select=E721, --fix] + - id: ruff-check + name: Ruff all pycodestyle Errors + args: ["--select=E"] + - id: ruff-check + name: Ruff all pyflakes Errors + args: ["--select=F"] + - id: ruff-check + name: Ruff trailing missing comma + args: ["--select=COM812", --fix] + - id: ruff-check + name: Ruff TODO formats + args: ["--select=TD"] + - id: ruff-check + name: Ruff Bugbear + args: ["--select=B006"] + - id: ruff-check + name: Ruff pylint conventions + args: ["--select=PLC"] + - id: ruff-check + name: Ruff commented code + args: ["--select=ERA"] + - id: ruff-check + name: Ruff flake gettext + args: ["--select=INT"] + - id: ruff-check + name: Ruff logging format + args: ["--select=LOG"] + - id: ruff-check + name: Ruff pytest format + args: ["--select=PT"] + - id: ruff-check + name: Ruff implicit string concat + args: ["--select=ISC"] + - id: ruff-check + name: Ruff loggin percent format + args: ["--select=G"] + - id: ruff-check + name: Ruff executable permissions + args: ["--select=EXE"] + - id: ruff-check + name: Ruff type checking + args: ["--select=TC"] + - id: ruff-check + name: Ruff idiomatic code + args: ["--select=PIE"] + - id: ruff-check + name: Ruff print + args: ["--select=T"] + - id: ruff-check + name: Ruff Needless pass + args: ["--select=SIM"] diff --git a/.vscode/launch.json b/.vscode/launch.json index 34da5e1..c9e01f7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,9 +2,8 @@ "version": "0.2.0", "configurations": [ { - "envFile": "${workspaceFolder}/.env", - "name": "Python: Uvicorn", + "envFile": "${workspaceFolder}/.env", "type": "debugpy", "request": "launch", "module": "uvicorn", @@ -15,9 +14,8 @@ ] }, { - "envFile": "${workspaceFolder}/.env", - "name": "Python: Run Single File", + "envFile": "${workspaceFolder}/.env", "type": "debugpy", "request": "launch", "program": "${file}", @@ -25,4 +23,4 @@ "justMyCode": true, } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index de288e1..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "python.formatting.provider": "black" -} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 4d76647..30952e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,12 @@ -FROM python:3.10-alpine -LABEL MAINTAINER="Jonathan Villanueva " -RUN apk update && apk add --no-cache python3-dev \ - gcc \ - libc-dev \ - libffi-dev -WORKDIR /app/ -ADD . /app/ -RUN pip install --no-cache-dir -r requirements.txt -EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--workers", "2", "--host", "0.0.0.0" ] \ No newline at end of file +FROM python:3.13-alpine +LABEL MAINTAINER="Jonathan Villanueva " +RUN apk update && apk add --no-cache python3-dev \ + gcc \ + libc-dev \ + libffi-dev +WORKDIR /app/ +COPY requirements.txt /app/ +RUN pip install --no-cache-dir -r requirements.txt +COPY . /app/ +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--workers", "2", "--host", "0.0.0.0" ] diff --git a/app/auth/auth.py b/app/auth/auth.py index a2d3f42..d2fd7a9 100644 --- a/app/auth/auth.py +++ b/app/auth/auth.py @@ -1,102 +1,101 @@ -from typing import Annotated -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPBasic, HTTPBasicCredentials -from datetime import datetime, timedelta -from typing import Annotated - -from app.models.token import TokenData -from app.models.user import UserInDB -from passlib.context import CryptContext -from jose import JWTError, jwt -from app.core import configuration -from app.services.user import UserService -from fastapi.security import OAuth2PasswordBearer - -SECRET_KEY = configuration.APP_SECRET_KEY -ALGORITHM = configuration.ALGORITHM - -security = HTTPBasic() - - -def get_current_username_admin( - credentials: Annotated[HTTPBasicCredentials, Depends(security)] -): - user = UserService.get_user_active(credentials.username) - if user is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect email or password", - headers={"WWW-Authenticate": "Basic"}, - ) - if not verify_password(credentials.password, user.hashed_password): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect email or password", - headers={"WWW-Authenticate": "Basic"}, - ) - if not user.admin: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="User is not admin", - headers={"WWW-Authenticate": "Basic"}, - ) - return credentials.username - - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - - -def verify_password(plain_password, hashed_password): - return pwd_context.verify(plain_password, hashed_password) - - -def get_password_hash(password): - return pwd_context.hash(password) - - -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") - - -async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]): - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - username: str = payload.get("sub") - if username is None: - raise credentials_exception - token_data = TokenData(username=username) - except JWTError: - raise credentials_exception - user = UserService.get_user_active(username=token_data.username) - if user is None: - raise credentials_exception - if not user.editor: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="User is not editor", - headers={"WWW-Authenticate": "Bearer"}, - ) - return user - - -async def get_current_active_user( - current_user: Annotated[UserInDB, Depends(get_current_user)] -): - if current_user.disabled: - raise HTTPException(status_code=400, detail="Inactive user") - return current_user - - -def create_access_token(data: dict, expires_delta: timedelta | None = None): - to_encode = data.copy() - if expires_delta: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(minutes=15) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) - return encoded_jwt, expire +from datetime import datetime, timedelta +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials, OAuth2PasswordBearer +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.core import configuration +from app.models.token import TokenData +from app.models.user import UserInDB +from app.services.user import UserService + +SECRET_KEY = configuration.APP_SECRET_KEY +ALGORITHM = configuration.ALGORITHM + +security = HTTPBasic() + + +def get_current_username_admin( + credentials: Annotated[HTTPBasicCredentials, Depends(security)], +): + user = UserService.get_user_active(credentials.username) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Basic"}, + ) + if not verify_password(credentials.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Basic"}, + ) + if not user.admin: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User is not admin", + headers={"WWW-Authenticate": "Basic"}, + ) + return credentials.username + + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password): + return pwd_context.hash(password) + + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") + + +async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + token_data = TokenData(username=username) + except JWTError: + raise credentials_exception + user = UserService.get_user_active(username=token_data.username) + if user is None: + raise credentials_exception + if not user.editor: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User is not editor", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user + + +async def get_current_active_user( + current_user: Annotated[UserInDB, Depends(get_current_user)], +): + if current_user.disabled: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + + +def create_access_token(data: dict, expires_delta: timedelta | None = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt, expire diff --git a/app/core/configuration.py b/app/core/configuration.py index aab3d8a..ec2a8c9 100644 --- a/app/core/configuration.py +++ b/app/core/configuration.py @@ -1,11 +1,22 @@ -import os -from datetime import datetime -APP_TITLE = os.getenv("APP_TITLE", "MY NEW API") -APP_VERSION = os.getenv("APP_VERSION", "1.0.0") -APP_MONGO_URL = os.getenv("APP_MONGO_URL", "mongodb://user:password@localhost/db") -APP_MONGO_DB = os.getenv("APP_MONGO_DB", "db") -APP_SECRET_KEY = os.getenv("APP_SECRET_KEY", "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7") -APP_ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("APP_ACCESS_TOKEN_EXPIRE_MINUTES", "30")) -APP_USERS_API_URL = os.getenv("APP_USERS_API_URL", "http://localhost:8000") -APP_USERS_API_TOKEN = os.getenv("APP_USERS_API_TOKEN", "token") -ALGORITHM = "HS256" \ No newline at end of file +import os + +APP_TITLE = os.getenv("APP_TITLE", "MY NEW API") +APP_VERSION = os.getenv("APP_VERSION", "1.0.0") +APP_MONGO_URL = os.getenv("APP_MONGO_URL", "mongodb://user:password@localhost/db") +APP_MONGO_DB = os.getenv("APP_MONGO_DB", "db") +APP_SECRET_KEY = os.getenv( + "APP_SECRET_KEY", + "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7", +) +APP_ACCESS_TOKEN_EXPIRE_MINUTES = int( + os.getenv("APP_ACCESS_TOKEN_EXPIRE_MINUTES", "30"), +) +APP_USERS_API_URL = os.getenv("APP_USERS_API_URL", "http://localhost:8000") +APP_USERS_API_TOKEN = os.getenv("APP_USERS_API_TOKEN", "token") +APP_SMTP_HOST = os.getenv("APP_SMTP_HOST", "") +APP_SMTP_PORT = int(os.getenv("APP_SMTP_PORT", "587")) +APP_SMTP_USER = os.getenv("APP_SMTP_USER", "") +APP_SMTP_PASSWORD = os.getenv("APP_SMTP_PASSWORD", "") +APP_SMTP_FROM = os.getenv("APP_SMTP_FROM", "") +APP_SMTP_STARTTLS = os.getenv("APP_SMTP_STARTTLS", "true").lower() == "true" +ALGORITHM = "HS256" diff --git a/app/core/database.py b/app/core/database.py index 3c302b5..b2ed79a 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -1,6 +1,30 @@ -import pymongo - -from app.core import configuration - -client = pymongo.MongoClient(configuration.APP_MONGO_URL) -db = client.get_database(configuration.APP_MONGO_DB) +import logging + +import pymongo +from pymongo.errors import OperationFailure + +from app.core import configuration + +logger = logging.getLogger(__name__) + +client = pymongo.MongoClient(configuration.APP_MONGO_URL) +db = client.get_database(configuration.APP_MONGO_DB) + + +def ensure_indexes() -> None: + try: + db.redeem_meals.create_index( + "participant_control_code", + unique=True, + name="unique_meal_per_participant", + partialFilterExpression={"disabled": False}, + ) + db.redeem_gifts.create_index( + [("participant_control_code", 1), ("gift", 1)], + unique=True, + name="unique_single_gift_per_participant", + partialFilterExpression={"disabled": False, "multiple": False}, + ) + except OperationFailure: + # Usually existing duplicated data; the app keeps working with app-level checks + logger.exception("Failed to create unique indexes") diff --git a/app/core/roles.py b/app/core/roles.py index 723ad80..19446d0 100644 --- a/app/core/roles.py +++ b/app/core/roles.py @@ -1,17 +1,28 @@ -from typing import List - - -class Roles: - # accreditation, checker, launch, swag, information - ACCREDITATION = "accreditation" - CHECKER = "checker" - LAUNCH = "launch" - SWAG = "swag" - INFORMATION = "information" - SPECIAL_POINT = "special_point" - REVOCATION = "revocation" - COMBO = "combo" - - @staticmethod - def get_roles() -> List[str]: - return [Roles.ACCREDITATION, Roles.CHECKER, Roles.LAUNCH, Roles.SWAG, Roles.INFORMATION, Roles.SPECIAL_POINT, Roles.REVOCATION, Roles.COMBO] \ No newline at end of file +from typing import List + + +class Roles: + # accreditation, checker, launch, swag, information + ADMIN = "admin" + ACCREDITATION = "accreditation" + CHECKER = "checker" + LAUNCH = "launch" + SWAG = "swag" + INFORMATION = "information" + SPECIAL_POINT = "special_point" + REVOCATION = "revocation" + COMBO = "combo" + + @staticmethod + def get_roles() -> List[str]: + return [ + Roles.ADMIN, + Roles.ACCREDITATION, + Roles.CHECKER, + Roles.LAUNCH, + Roles.SWAG, + Roles.INFORMATION, + Roles.SPECIAL_POINT, + Roles.REVOCATION, + Roles.COMBO, + ] diff --git a/app/main.py b/app/main.py index d257a2f..c48a56b 100644 --- a/app/main.py +++ b/app/main.py @@ -1,88 +1,127 @@ -from app.auth.auth import get_current_username_admin -from app.core import configuration -from typing import Optional -from fastapi import Depends, FastAPI, Request -from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html -from fastapi.openapi.utils import get_openapi -from fastapi.middleware.cors import CORSMiddleware -from app.routers import ( - auth, - control, - gifts, - locations, - meals, - participants, - combos, - points, - presentations, - products, - buy, - special_point, -) -from fastapi.responses import JSONResponse - -TITLE = configuration.APP_TITLE -VERSION = configuration.APP_VERSION -app = FastAPI( - title=TITLE, - version=VERSION, - openapi_url=None, - docs_url=None, - redoc_url=None, -) -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], -) - - -@app.get("/api/v1") -async def root(): - return {"message": "Hello World"} - - -@app.get("/api/v1/docs", tags=["Documentation"]) # Tag it as "documentation" for our docs -async def get_documentation( - request: Request, user: Optional[dict] = Depends(get_current_username_admin) -): # This dependency protects our endpoint! - response = get_swagger_ui_html( - openapi_url="/api/v1/openapi.json", - title="Documentation", - ) - return response - - -@app.get("/api/v1/openapi.json", tags=["Documentation"]) -async def get_open_api_endpoint( - request: Request, user: Optional[dict] = Depends(get_current_username_admin) -): # This dependency protects our endpoint! - response = JSONResponse( - get_openapi(title=TITLE, version=VERSION, routes=app.routes) - ) - return response - - -@app.get("/api/v1/redoc", tags=["Documentation"]) # Tag it as "documentation" for our docs -async def redoc_html( - request: Request, user: Optional[dict] = Depends(get_current_username_admin) -): # This dependency protects our endpoint! - response = get_redoc_html(openapi_url="/api/v1/openapi.json", title="Documentation") - return response - - -app.include_router(auth.router, prefix="/api/v1/auth", tags=["Security"]) -app.include_router(locations.router, prefix="/api/v1/location", tags=["Location"]) -app.include_router( - presentations.router, prefix="/api/v1/presentation", tags=["Presentations"] -) -app.include_router(participants.router, prefix="/api/v1/participant", tags=["Participant"]) -app.include_router(products.router, prefix="/api/v1/product", tags=["Product"]) -app.include_router(combos.router, prefix="/api/v1/combo", tags=["Combo"]) -app.include_router(gifts.router, prefix="/api/v1/gift", tags=["Gift"]) -app.include_router(control.router, prefix="/api/v1/control", tags=["Control"]) -app.include_router(points.router, prefix="/api/v1/points", tags=["Points"]) -app.include_router(meals.router, prefix="/api/v1/meals", tags=["Meals"]) -app.include_router(buy.router, prefix="/api/v1/buy", tags=["Buy"]) -app.include_router(special_point.router, prefix="/api/v1/special_point", tags=["Special Point"]) +from typing import Optional + +from fastapi import Depends, FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html +from fastapi.openapi.utils import get_openapi +from fastapi.responses import JSONResponse + +from app.auth.auth import get_current_username_admin +from app.core import configuration +from app.core.database import ensure_indexes +from app.routers import ( + auth, + buy, + combos, + control, + gifts, + locations, + meals, + participants, + points, + presentation_checkpoint_links, + presentations, + products, + self_service, + special_point, +) + +TITLE = configuration.APP_TITLE +VERSION = configuration.APP_VERSION +app = FastAPI( + title=TITLE, + version=VERSION, + openapi_url=None, + docs_url=None, + redoc_url=None, +) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.on_event("startup") +def startup(): + ensure_indexes() + + +@app.get("/api/v1") +async def root(): + return {"message": "Hello World"} + + +@app.get( + "/api/v1/docs", + tags=["Documentation"], +) # Tag it as "documentation" for our docs +async def get_documentation( + request: Request, + user: Optional[dict] = Depends(get_current_username_admin), +): # This dependency protects our endpoint! + response = get_swagger_ui_html( + openapi_url="/api/v1/openapi.json", + title="Documentation", + ) + return response + + +@app.get("/api/v1/openapi.json", tags=["Documentation"]) +async def get_open_api_endpoint( + request: Request, + user: Optional[dict] = Depends(get_current_username_admin), +): # This dependency protects our endpoint! + response = JSONResponse( + get_openapi(title=TITLE, version=VERSION, routes=app.routes), + ) + return response + + +@app.get( + "/api/v1/redoc", + tags=["Documentation"], +) # Tag it as "documentation" for our docs +async def redoc_html( + request: Request, + user: Optional[dict] = Depends(get_current_username_admin), +): # This dependency protects our endpoint! + response = get_redoc_html(openapi_url="/api/v1/openapi.json", title="Documentation") + return response + + +app.include_router(auth.router, prefix="/api/v1/auth", tags=["Security"]) +app.include_router(locations.router, prefix="/api/v1/location", tags=["Location"]) +app.include_router( + presentations.router, + prefix="/api/v1/presentation", + tags=["Presentations"], +) +app.include_router( + presentation_checkpoint_links.router, + prefix="/api/v1/presentation-checkpoint-link", + tags=["Presentation Checkpoint Links"], +) +app.include_router( + participants.router, + prefix="/api/v1/participant", + tags=["Participant"], +) +app.include_router(products.router, prefix="/api/v1/product", tags=["Product"]) +app.include_router(combos.router, prefix="/api/v1/combo", tags=["Combo"]) +app.include_router(gifts.router, prefix="/api/v1/gift", tags=["Gift"]) +app.include_router(control.router, prefix="/api/v1/control", tags=["Control"]) +app.include_router(points.router, prefix="/api/v1/points", tags=["Points"]) +app.include_router(meals.router, prefix="/api/v1/meals", tags=["Meals"]) +app.include_router(buy.router, prefix="/api/v1/buy", tags=["Buy"]) +app.include_router( + special_point.router, + prefix="/api/v1/special_point", + tags=["Special Point"], +) +app.include_router( + self_service.router, + prefix="/api/v1/self-service", + tags=["Self Service"], +) diff --git a/app/models/base.py b/app/models/base.py index 365e5e9..3574b99 100644 --- a/app/models/base.py +++ b/app/models/base.py @@ -1,11 +1,8 @@ -from datetime import datetime -from typing import Optional - -from bson import ObjectId -from pydantic import BaseModel - - -class Base(BaseModel): - class Config: - arbitrary_types_allowed = True - json_encoders = {ObjectId: str} +from bson import ObjectId +from pydantic import BaseModel + + +class Base(BaseModel): + class Config: + arbitrary_types_allowed = True + json_encoders = {ObjectId: str} diff --git a/app/models/buy.py b/app/models/buy.py index fe49401..4243465 100644 --- a/app/models/buy.py +++ b/app/models/buy.py @@ -1,22 +1,23 @@ -from datetime import datetime -from typing import Optional - -from pydantic import Field -from app.models.base import Base -from app.utils.mongo_validator import PyObjectId - - -class BuyProduct(Base): - id: Optional[PyObjectId] = Field(alias="_id", default=None) - participant_control_code: str = "" - product: PyObjectId = None - date: datetime = None - user_control: str = "" - - -class BuyProductInDB(BuyProduct): - disabled: Optional[bool] = False - date_insert: Optional[datetime] = None - date_update: Optional[datetime] = None - username_insert: Optional[str] = None - username_update: Optional[str] = None \ No newline at end of file +from datetime import datetime +from typing import Optional + +from pydantic import Field + +from app.models.base import Base +from app.utils.mongo_validator import PyObjectId + + +class BuyProduct(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + participant_control_code: str = "" + product: PyObjectId = None + date: datetime = None + user_control: str = "" + + +class BuyProductInDB(BuyProduct): + disabled: Optional[bool] = False + date_insert: Optional[datetime] = None + date_update: Optional[datetime] = None + username_insert: Optional[str] = None + username_update: Optional[str] = None diff --git a/app/models/combo.py b/app/models/combo.py index b413c71..12199fa 100644 --- a/app/models/combo.py +++ b/app/models/combo.py @@ -1,48 +1,51 @@ -from datetime import datetime -from typing import List, Optional - -from pydantic import Field -from app.models.base import Base -from app.utils.mongo_validator import PyObjectId - -class Products(Base): - id: Optional[PyObjectId] = Field(alias="_id", default=None) - name: str = "" - -class Combo(Base): - id: Optional[PyObjectId] = Field(alias="_id", default=None) - name: str = "" - description: str = "" - products: List[PyObjectId] = [] - products_items: List[Products] = [] - - -class ComboInDB(Combo): - disabled: Optional[bool] = False - date_insert: Optional[datetime] = None - date_update: Optional[datetime] = None - username_insert: Optional[str] = None - username_update: Optional[str] = None - - -class RedeemCombo(Base): - id: Optional[PyObjectId] = Field(alias="_id", default=None) - participant_control_code: str = "" - combo: PyObjectId = None - products: List[PyObjectId] = [] - date: datetime = None - user_control: str = "" - message: str = "" - - -class RedeemComboInDB(RedeemCombo): - disabled: Optional[bool] = False - date_insert: Optional[datetime] = None - date_update: Optional[datetime] = None - username_insert: Optional[str] = None - - -class RedeemComboRequest(Base): - participant_control_code: str = "" - combo_id: PyObjectId = None - products_id: List[PyObjectId] = [] +from datetime import datetime +from typing import List, Optional + +from pydantic import Field + +from app.models.base import Base +from app.utils.mongo_validator import PyObjectId + + +class Products(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + name: str = "" + + +class Combo(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + name: str = "" + description: str = "" + products: List[PyObjectId] = [] + products_items: List[Products] = [] + + +class ComboInDB(Combo): + disabled: Optional[bool] = False + date_insert: Optional[datetime] = None + date_update: Optional[datetime] = None + username_insert: Optional[str] = None + username_update: Optional[str] = None + + +class RedeemCombo(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + participant_control_code: str = "" + combo: PyObjectId = None + products: List[PyObjectId] = [] + date: datetime = None + user_control: str = "" + message: str = "" + + +class RedeemComboInDB(RedeemCombo): + disabled: Optional[bool] = False + date_insert: Optional[datetime] = None + date_update: Optional[datetime] = None + username_insert: Optional[str] = None + + +class RedeemComboRequest(Base): + participant_control_code: str = "" + combo_id: PyObjectId = None + products_id: List[PyObjectId] = [] diff --git a/app/models/control.py b/app/models/control.py index 9be8091..07a05cc 100644 --- a/app/models/control.py +++ b/app/models/control.py @@ -1,24 +1,25 @@ -from datetime import datetime -from typing import Literal, Optional - -from pydantic import Field -from app.models.base import Base -from app.utils.mongo_validator import PyObjectId - - -class Control(Base): - id: Optional[PyObjectId] = Field(alias="_id", default=None) - participant_control_code: str = "" - location: PyObjectId = None - location_name: str = "" - control: Literal["in", "out"] - date: datetime = None - user_control: str = "" - - -class ControlInDB(Control): - disabled: Optional[bool] = False - date_insert: Optional[datetime] = None - date_update: Optional[datetime] = None - username_insert: Optional[str] = None - username_update: Optional[str] = None +from datetime import datetime +from typing import Literal, Optional + +from pydantic import Field + +from app.models.base import Base +from app.utils.mongo_validator import PyObjectId + + +class Control(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + participant_control_code: str = "" + location: PyObjectId = None + location_name: str = "" + control: Literal["in", "out"] + date: datetime = None + user_control: str = "" + + +class ControlInDB(Control): + disabled: Optional[bool] = False + date_insert: Optional[datetime] = None + date_update: Optional[datetime] = None + username_insert: Optional[str] = None + username_update: Optional[str] = None diff --git a/app/models/gift.py b/app/models/gift.py index 33117f4..90aedbd 100644 --- a/app/models/gift.py +++ b/app/models/gift.py @@ -1,38 +1,53 @@ -from datetime import datetime -from typing import Optional - -from pydantic import Field -from app.models.base import Base -from app.utils.mongo_validator import PyObjectId - - -class Gift(Base): - id: Optional[PyObjectId] = Field(alias="_id", default=None) - product: PyObjectId = None - product_name: Optional[str] = None - points: int = 0 - multiple: bool = False - - -class GiftInDB(Gift): - disabled: Optional[bool] = False - date_insert: Optional[datetime] = None - date_update: Optional[datetime] = None - username_insert: Optional[str] = None - username_update: Optional[str] = None - - -class RedeemGift(Base): - id: Optional[PyObjectId] = Field(alias="_id", default=None) - participant_control_code: str = "" - gift: PyObjectId = None - date: datetime = None - user_control: str = "" - - -class RedeemGiftInDB(RedeemGift): - disabled: Optional[bool] = False - date_insert: Optional[datetime] = None - date_update: Optional[datetime] = None - username_insert: Optional[str] = None - username_update: Optional[str] = None +from datetime import datetime +from typing import Optional + +from pydantic import Field + +from app.models.base import Base +from app.utils.mongo_validator import PyObjectId + + +class Gift(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + product: PyObjectId = None + product_name: Optional[str] = None + points: int = 0 + multiple: bool = False + + +class GiftInDB(Gift): + disabled: Optional[bool] = False + date_insert: Optional[datetime] = None + date_update: Optional[datetime] = None + username_insert: Optional[str] = None + username_update: Optional[str] = None + + +class RedeemGift(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + participant_control_code: str = "" + gift: PyObjectId = None + date: datetime = None + user_control: str = "" + product: Optional[PyObjectId] = None + discount_code: Optional[str] = None + download_url: Optional[str] = None + multiple: bool = False + + +class SelfServiceGift(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + name: str = "" + description: str = "" + kind: str = "" + points: int = 0 + multiple: bool = False + stock: Optional[int] = None + + +class RedeemGiftInDB(RedeemGift): + disabled: Optional[bool] = False + date_insert: Optional[datetime] = None + date_update: Optional[datetime] = None + username_insert: Optional[str] = None + username_update: Optional[str] = None diff --git a/app/models/location.py b/app/models/location.py index 4e10395..f5ff74b 100644 --- a/app/models/location.py +++ b/app/models/location.py @@ -1,20 +1,21 @@ -from datetime import datetime -from typing import Optional - -from pydantic import Field -from app.models.base import Base -from app.utils.mongo_validator import PyObjectId - - -class Location(Base): - id: Optional[PyObjectId] = Field(alias="_id", default=None) - name: str = "" - value: str = "" - - -class LocationInDB(Location): - disabled: Optional[bool] = False - date_insert: Optional[datetime] = None - date_update: Optional[datetime] = None - username_insert: Optional[str] = None - username_update: Optional[str] = None +from datetime import datetime +from typing import Optional + +from pydantic import Field + +from app.models.base import Base +from app.utils.mongo_validator import PyObjectId + + +class Location(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + name: str = "" + value: str = "" + + +class LocationInDB(Location): + disabled: Optional[bool] = False + date_insert: Optional[datetime] = None + date_update: Optional[datetime] = None + username_insert: Optional[str] = None + username_update: Optional[str] = None diff --git a/app/models/meal.py b/app/models/meal.py index 34aa82d..b91f7fd 100644 --- a/app/models/meal.py +++ b/app/models/meal.py @@ -1,21 +1,25 @@ -from datetime import datetime -from typing import Optional - -from pydantic import Field -from app.models.base import Base -from app.utils.mongo_validator import PyObjectId - - -class RedeemMeal(Base): - id: Optional[PyObjectId] = Field(alias="_id", default=None) - participant_control_code: str = "" - date: datetime = None - user_control: str = "" - - -class RedeemMealInDB(RedeemMeal): - disabled: Optional[bool] = False - date_insert: Optional[datetime] = None - date_update: Optional[datetime] = None - username_insert: Optional[str] = None - username_update: Optional[str] = None +from datetime import datetime +from typing import Optional + +from pydantic import Field + +from app.models.base import Base +from app.utils.mongo_validator import PyObjectId + + +class RedeemMeal(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + participant_control_code: str = "" + date: datetime = None + user_control: str = "" + product: Optional[PyObjectId] = None + discount_code: Optional[str] = None + download_url: Optional[str] = None + + +class RedeemMealInDB(RedeemMeal): + disabled: Optional[bool] = False + date_insert: Optional[datetime] = None + date_update: Optional[datetime] = None + username_insert: Optional[str] = None + username_update: Optional[str] = None diff --git a/app/models/participant.py b/app/models/participant.py index 6f48219..75e3b80 100644 --- a/app/models/participant.py +++ b/app/models/participant.py @@ -1,51 +1,87 @@ -from datetime import datetime -from typing import List, Literal, Optional - -from pydantic import Field -from app.models.base import Base -from app.utils.mongo_validator import PyObjectId - - -class Participant(Base): - id: Optional[PyObjectId] = Field(alias="_id", default=None) - mail: str = "" - name: str = "" - birthday: datetime = None - gender: str = "" - ci: str = "" - profession: str = "" - work_place: str = "" - residence_place: str = "" - photo: str = "" - phone: str = "" - t_shirt_size: str = "" - food_restriction: str = "" - control_code: str = "" - registration: bool = False - combos: List[PyObjectId] = [] - locations: List[PyObjectId] = [] - combos_items: List[str] = [] - locations_items: List[str] = [] - role: Literal["user", "organizer", "speaker"] = "user" - points: int = 0 - - -class ParticipantInDB(Participant): - disabled: Optional[bool] = False - date_insert: Optional[datetime] = None - date_update: Optional[datetime] = None - username_insert: Optional[str] = None - username_update: Optional[str] = None - - -class RevokeRegistration(Base): - control_code: str = "" - ci: str = "" - participant_id = PyObjectId - -class RevokeRegistrationInDB(RevokeRegistration): - disabled: Optional[bool] = False - date_insert: Optional[datetime] = None - date_update: Optional[datetime] = None - username_insert: Optional[str] = None - username_update: Optional[str] = None \ No newline at end of file +from datetime import date, datetime +from typing import List, Literal, Optional + +from pydantic import Field + +from app.models.base import Base +from app.utils.mongo_validator import PyObjectId + + +class Participant(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + mail: str = "" + name: str = "" + birthday: datetime + gender: str = "" + ci: str = "" + profession: str = "" + work_place: str = "" + residence_place: str = "" + photo: str = "" + phone: str = "" + t_shirt_size: str = "" + food_restriction: str = "" + control_code: str = "" + registration: bool = False + date_registration: Optional[datetime] = None + combos: List[PyObjectId] = [] + locations: List[PyObjectId] = [] + combos_items: List[str] = [] + locations_items: List[str] = [] + role: Literal["user", "organizer", "speaker"] = "user" + points: int = 0 + + +class ParticipantInDB(Participant): + disabled: Optional[bool] = False + date_insert: Optional[datetime] = None + date_update: Optional[datetime] = None + username_insert: Optional[str] = None + username_update: Optional[str] = None + + +class RevokeRegistration(Base): + control_code: str = "" + ci: str = "" + participant_id = PyObjectId + + +class RevokeRegistrationInDB(RevokeRegistration): + disabled: Optional[bool] = False + date_insert: Optional[datetime] = None + date_update: Optional[datetime] = None + username_insert: Optional[str] = None + username_update: Optional[str] = None + + +class SelfServiceParticipant(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + control_code: str = "" + qr_code: str = "" + + +class SelfServiceResult(Base): + control_code: str = "" + message: str = "" + points: int = 0 + + +class SelfServiceRedeem(SelfServiceResult): + product_name: str = "" + discount_code: Optional[str] = None + download_url: Optional[str] = None + + +class SelfServiceRegisterRequest(Base): + ci: str + birthday: date + + +class SelfServiceCheckRequest(Base): + control_code: str + + +class SelfServiceRedeemRequest(Base): + control_code: str + ci: str + gift: PyObjectId | None = None diff --git a/app/models/point.py b/app/models/point.py index 9d5bf7e..dbaf287 100644 --- a/app/models/point.py +++ b/app/models/point.py @@ -1,24 +1,26 @@ -from datetime import datetime -from typing import Optional - -from pydantic import Field -from app.models.base import Base -from app.utils.mongo_validator import PyObjectId - - -class Point(Base): - id: Optional[PyObjectId] = Field(alias="_id", default=None) - participant_control_code: str = "" - presentation: PyObjectId = None - points: int = 0 - date: datetime = None - user_control: str = "" - is_participation: bool = False - - -class PointInDB(Point): - disabled: Optional[bool] = False - date_insert: Optional[datetime] = None - date_update: Optional[datetime] = None - username_insert: Optional[str] = None - username_update: Optional[str] = None +from datetime import datetime +from typing import Optional + +from pydantic import Field + +from app.models.base import Base +from app.utils.mongo_validator import PyObjectId + + +class Point(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + participant_control_code: str = "" + presentation: PyObjectId = None + points: int = 0 + date: datetime = None + user_control: str = "" + is_participation: bool = False + checkpoint_link: Optional[PyObjectId] = None + + +class PointInDB(Point): + disabled: Optional[bool] = False + date_insert: Optional[datetime] = None + date_update: Optional[datetime] = None + username_insert: Optional[str] = None + username_update: Optional[str] = None diff --git a/app/models/presentation.py b/app/models/presentation.py index 04c2452..75646d4 100644 --- a/app/models/presentation.py +++ b/app/models/presentation.py @@ -1,25 +1,43 @@ -from datetime import datetime -from typing import Literal, Optional - -from pydantic import Field -from app.models.base import Base -from app.utils.mongo_validator import PyObjectId - - -class Presentation(Base): - id: Optional[PyObjectId] = Field(alias="_id", default=None) - name: str = "" - speaker: str = "" - description: str = "" - kind: Literal["talk", "workshop"] = "talk" - points: int = 0 - participation_points: int = 0 - location: PyObjectId = Field(alias="location", default=None) - - -class PresentationInDB(Presentation): - disabled: Optional[bool] = False - date_insert: Optional[datetime] = None - date_update: Optional[datetime] = None - username_insert: Optional[str] = None - username_update: Optional[str] = None +from datetime import datetime +from typing import Literal, Optional + +from pydantic import Field + +from app.models.base import Base +from app.utils.mongo_validator import PyObjectId + + +class Presentation(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + name: str = "" + speaker: str = "" + description: str = "" + kind: Literal["talk", "workshop"] = "talk" + points: int = 0 + participation_points: int = 0 + attendance_checkpoints: int = 1 + min_checkpoint_duration: int = 5 + location: PyObjectId = Field(alias="location", default=None) + + def checkpoint_points(self) -> dict[int, int]: + base, remainder = divmod( + self.points, + self.attendance_checkpoints, + ) + + return { + checkpoint: base + + (1 if checkpoint > self.attendance_checkpoints - remainder else 0) + for checkpoint in range(1, self.attendance_checkpoints + 1) + } + + def checkpoint_point(self, checkpoint: int) -> int: + return self.checkpoint_points().get(checkpoint, 0) + + +class PresentationInDB(Presentation): + disabled: Optional[bool] = False + date_insert: Optional[datetime] = None + date_update: Optional[datetime] = None + username_insert: Optional[str] = None + username_update: Optional[str] = None diff --git a/app/models/presentation_checkpoint_link.py b/app/models/presentation_checkpoint_link.py new file mode 100644 index 0000000..784a340 --- /dev/null +++ b/app/models/presentation_checkpoint_link.py @@ -0,0 +1,26 @@ +from datetime import datetime + +from pydantic import Field + +from app.models.base import Base +from app.utils.mongo_validator import PyObjectId + + +class PresentationCheckpointLink(Base): + id: PyObjectId | None = Field(alias="_id", default=None) + presentation: PyObjectId | None = Field(alias="presentation", default=None) + checkpoint: int = 0 + points: int = 0 + code: str = Field(min_length=5, max_length=8, pattern=r"^[A-Za-z0-9]+$") + min_duration: int = 5 + active: bool = True + start_duration: datetime | None = None + end_duration: datetime | None = None + + +class PresentationCheckpointLinkInDB(PresentationCheckpointLink): + disabled: bool | None = False + date_insert: datetime | None = None + date_update: datetime | None = None + username_insert: str = "" + username_update: str = "" diff --git a/app/models/product.py b/app/models/product.py index 27e5243..8e94535 100644 --- a/app/models/product.py +++ b/app/models/product.py @@ -1,23 +1,36 @@ -from datetime import datetime -from typing import Optional - -from pydantic import Field -from app.models.base import Base -from app.utils.mongo_validator import PyObjectId - - -class Product(Base): - id: Optional[PyObjectId] = Field(alias="_id", default=None) - name: str = "" - description: str = "" - able_to_buy: bool = False - price: float = 0.0 - currency: str = "" - - -class ProductInDB(Product): - disabled: Optional[bool] = False - date_insert: Optional[datetime] = None - date_update: Optional[datetime] = None - username_insert: Optional[str] = None - username_update: Optional[str] = None +from datetime import datetime +from typing import List, Literal, Optional + +from pydantic import Field + +from app.models.base import Base +from app.utils.mongo_validator import PyObjectId + + +class DiscountCode(Base): + code: str + assigned_to: Optional[str] = None + date_assigned: Optional[datetime] = None + + +class Product(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + name: str = "" + description: str = "" + able_to_buy: bool = False + price: float = 0.0 + currency: str = "" + kind: Literal["physical", "digital_file", "discount_code"] = "physical" + # None means unlimited stock + stock: Optional[int] = None + file_url: str = "" + is_virtual_meal: bool = False + discount_codes: List[DiscountCode] = [] + + +class ProductInDB(Product): + disabled: Optional[bool] = False + date_insert: Optional[datetime] = None + date_update: Optional[datetime] = None + username_insert: Optional[str] = None + username_update: Optional[str] = None diff --git a/app/models/special_point.py b/app/models/special_point.py index 6d950d1..3dbd2d0 100644 --- a/app/models/special_point.py +++ b/app/models/special_point.py @@ -1,35 +1,39 @@ -from datetime import datetime -from typing import Optional - -from pydantic import Field -from app.models.base import Base -from app.utils.mongo_validator import PyObjectId - - -class SpecialPoint(Base): - id: Optional[PyObjectId] = Field(alias="_id", default=None) - points : int = 0 - description: str = "" - limit: int = 0 - -class SpecialPointInDB(SpecialPoint): - disabled: Optional[bool] = False - date_insert: Optional[datetime] = None - date_update: Optional[datetime] = None - username_insert: Optional[str] = None - username_update: Optional[str] = None - -class SpecialPointRedeem(Base): - id: Optional[PyObjectId] = Field(alias="_id", default=None) - special_point: PyObjectId = None - points : int = 0 - participant_control_code: str = "" - date: datetime = None - user_control: str = "" - -class SpecialPointRedeemInDB(SpecialPointRedeem): - disabled: Optional[bool] = False - date_insert: Optional[datetime] = None - date_update: Optional[datetime] = None - username_insert: Optional[str] = None - username_update: Optional[str] = None \ No newline at end of file +from datetime import datetime +from typing import Optional + +from pydantic import Field + +from app.models.base import Base +from app.utils.mongo_validator import PyObjectId + + +class SpecialPoint(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + points: int = 0 + description: str = "" + limit: int = 0 + + +class SpecialPointInDB(SpecialPoint): + disabled: Optional[bool] = False + date_insert: Optional[datetime] = None + date_update: Optional[datetime] = None + username_insert: Optional[str] = None + username_update: Optional[str] = None + + +class SpecialPointRedeem(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + special_point: PyObjectId = None + points: int = 0 + participant_control_code: str = "" + date: datetime = None + user_control: str = "" + + +class SpecialPointRedeemInDB(SpecialPointRedeem): + disabled: Optional[bool] = False + date_insert: Optional[datetime] = None + date_update: Optional[datetime] = None + username_insert: Optional[str] = None + username_update: Optional[str] = None diff --git a/app/models/token.py b/app/models/token.py index 19dd67a..e0bebfb 100644 --- a/app/models/token.py +++ b/app/models/token.py @@ -1,13 +1,14 @@ -from datetime import datetime -from pydantic import BaseModel - - -class Token(BaseModel): - access_token: str - token_type: str - username: str = "" - expires: datetime = None - - -class TokenData(BaseModel): - username: str | None = None +from datetime import datetime + +from pydantic import BaseModel + + +class Token(BaseModel): + access_token: str + token_type: str + username: str = "" + expires: datetime = None + + +class TokenData(BaseModel): + username: str | None = None diff --git a/app/models/user.py b/app/models/user.py index 011ec4c..82a380e 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,24 +1,26 @@ -from datetime import datetime -from typing import List, Optional -from bson import ObjectId -from pydantic import BaseModel, Field -from app.models.base import Base -from app.utils.mongo_validator import PyObjectId -from app.core.roles import Roles - -class User(Base): - id: Optional[PyObjectId] = Field(alias="_id", default=None) - username: str - email: Optional[str] = None - given_name: Optional[str] = None - family_name: Optional[str] = None - disabled: Optional[bool] = False - admin: Optional[bool] = False - editor: Optional[bool] = False - roles: List[str] = [Roles.INFORMATION] - - -class UserInDB(User): - hashed_password: Optional[str] = None - date_insert: Optional[datetime] = None - date_update: Optional[datetime] = None +from datetime import datetime +from typing import List, Optional + +from pydantic import Field + +from app.core.roles import Roles +from app.models.base import Base +from app.utils.mongo_validator import PyObjectId + + +class User(Base): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + username: str + email: Optional[str] = None + given_name: Optional[str] = None + family_name: Optional[str] = None + disabled: Optional[bool] = False + admin: Optional[bool] = False + editor: Optional[bool] = False + roles: List[str] = [Roles.INFORMATION] + + +class UserInDB(User): + hashed_password: Optional[str] = None + date_insert: Optional[datetime] = None + date_update: Optional[datetime] = None diff --git a/app/routers/auth.py b/app/routers/auth.py index e91fcb0..255ebd0 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -1,166 +1,180 @@ -from datetime import timedelta -from typing import Annotated, List, Optional - -from fastapi import Depends, FastAPI, HTTPException, Response, status, APIRouter -from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from pydantic import BaseModel -from app.models.token import Token -from app.models.user import User, UserInDB -from app.services.user import UserService -from app.auth.auth import ( - create_access_token, - get_current_active_user, - get_current_username_admin, - get_password_hash, - verify_password, -) -from app.core import configuration - -ACCESS_TOKEN_EXPIRE_MINUTES = configuration.APP_ACCESS_TOKEN_EXPIRE_MINUTES - -router = APIRouter() - - -@router.post("/register", response_model=User) -async def register( - username: str, - password: str, - confirm_password: str, - email: str, - given_name: str = "", - family_name: str = "", - current_user: Optional[dict] = Depends(get_current_username_admin), -): - user = UserService.get_user(username) - if user is not None: - raise HTTPException(status_code=400, detail="Username already exists") - if password != confirm_password: - raise HTTPException( - status_code=400, detail="Password and confirm password not match" - ) - hashed_password = get_password_hash(password) - user = UserInDB( - username=username, - given_name=given_name, - family_name=family_name, - email=email, - hashed_password=hashed_password, - ) - inserted = UserService.create_user(user) - return inserted - - -@router.patch("/users/admin", response_model=User) -async def update_user_admin( - username: str, - admin: bool, - current_user: Optional[dict] = Depends(get_current_username_admin), -): - user = UserService.get_user_active(username) - if user is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="User not found" - ) - updated = UserService.set_admin(user.username, admin) - return updated - - -@router.patch("/users/editor", response_model=User) -async def update_user_admin( - username: str, - editor: bool, - current_user: Optional[dict] = Depends(get_current_username_admin), -): - user = UserService.get_user_active(username) - if user is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="User not found" - ) - updated = UserService.set_editor(user.username, editor) - return updated - - -@router.patch("/users/password/admin", response_model=User) -async def update_user_password_admin( - username: str, - password: str, - confirm_password: str, - current_user: Optional[dict] = Depends(get_current_username_admin), -): - if password != confirm_password: - raise HTTPException( - status_code=400, detail="Password and confirm password not match" - ) - - user = UserService.get_user_active(username) - if user is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="User not found" - ) - - hashed_password = get_password_hash(password) - updated = UserService.set_password(user.username, hashed_password) - return updated - - -@router.patch("/users/password", response_model=User) -async def update_user_password( - old_password: str, - password: str, - confirm_password: str, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - if password != confirm_password: - raise HTTPException( - status_code=400, detail="Password and confirm password not match" - ) - if not verify_password(old_password, current_user.hashed_password): - raise HTTPException(status_code=400, detail="Incorrect old password") - hashed_password = get_password_hash(password) - updated = UserService.set_password(current_user.username, hashed_password) - return updated - - -@router.post("/token", response_model=Token) -async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]): - user = UserService.get_user_active(form_data.username) - if user is None: - raise HTTPException(status_code=400, detail="Incorrect username or password") - if not verify_password(form_data.password, user.hashed_password): - raise HTTPException(status_code=400, detail="Incorrect username or password") - - access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - access_token, expires = create_access_token( - data={"sub": user.username}, expires_delta=access_token_expires - ) - return Token( - access_token=access_token, - token_type="bearer", - username=user.username, - expires=expires, - ) - - -@router.delete("/users", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) -async def delete_user( - username: str, current_user: Optional[dict] = Depends(get_current_username_admin) -): - user = UserService.get_user_active(username) - if user is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="User not found" - ) - deleted = UserService.delete_user(username) - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -@router.get("/users/me", response_model=User) -async def read_users_me( - current_user: Annotated[User, Depends(get_current_active_user)] -): - return current_user -@router.get("/users/me/roles", response_model=List[str]) -async def read_users_me_roles( - current_user: Annotated[User, Depends(get_current_active_user)] -): - return current_user.roles \ No newline at end of file +import logging +from datetime import timedelta +from typing import Annotated, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi.security import OAuth2PasswordRequestForm + +from app.auth.auth import ( + create_access_token, + get_current_active_user, + get_current_username_admin, + get_password_hash, + verify_password, +) +from app.core import configuration +from app.models.token import Token +from app.models.user import User, UserInDB +from app.services.user import UserService + +ACCESS_TOKEN_EXPIRE_MINUTES = configuration.APP_ACCESS_TOKEN_EXPIRE_MINUTES + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.post("/register", response_model=User) +async def register( + username: str, + password: str, + confirm_password: str, + email: str, + given_name: str = "", + family_name: str = "", + current_user: Optional[dict] = Depends(get_current_username_admin), +): + user = UserService.get_user(username) + if user is not None: + raise HTTPException(status_code=400, detail="Username already exists") + if password != confirm_password: + raise HTTPException( + status_code=400, + detail="Password and confirm password not match", + ) + hashed_password = get_password_hash(password) + user = UserInDB( + username=username, + given_name=given_name, + family_name=family_name, + email=email, + hashed_password=hashed_password, + ) + inserted = UserService.create_user(user) + return inserted + + +@router.patch("/users/admin", response_model=User) +async def update_user_admin( + username: str, + admin: bool, + current_user: Optional[dict] = Depends(get_current_username_admin), +): + user = UserService.get_user_active(username) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + updated = UserService.set_admin(user.username, admin) + return updated + + +@router.patch("/users/editor", response_model=User) +async def update_user_admin_editor( + username: str, + editor: bool, + current_user: Optional[dict] = Depends(get_current_username_admin), +): + user = UserService.get_user_active(username) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + updated = UserService.set_editor(user.username, editor) + return updated + + +@router.patch("/users/password/admin", response_model=User) +async def update_user_password_admin( + username: str, + password: str, + confirm_password: str, + current_user: Optional[dict] = Depends(get_current_username_admin), +): + if password != confirm_password: + raise HTTPException( + status_code=400, + detail="Password and confirm password not match", + ) + + user = UserService.get_user_active(username) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + hashed_password = get_password_hash(password) + updated = UserService.set_password(user.username, hashed_password) + return updated + + +@router.patch("/users/password", response_model=User) +async def update_user_password( + old_password: str, + password: str, + confirm_password: str, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + if password != confirm_password: + raise HTTPException( + status_code=400, + detail="Password and confirm password not match", + ) + if not verify_password(old_password, current_user.hashed_password): + raise HTTPException(status_code=400, detail="Incorrect old password") + hashed_password = get_password_hash(password) + updated = UserService.set_password(current_user.username, hashed_password) + return updated + + +@router.post("/token", response_model=Token) +async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]): + user = UserService.get_user_active(form_data.username) + if user is None: + raise HTTPException(status_code=400, detail="Incorrect username or password") + if not verify_password(form_data.password, user.hashed_password): + raise HTTPException(status_code=400, detail="Incorrect username or password") + + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token, expires = create_access_token( + data={"sub": user.username}, + expires_delta=access_token_expires, + ) + return Token( + access_token=access_token, + token_type="bearer", + username=user.username, + expires=expires, + ) + + +@router.delete("/users", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) +async def delete_user( + username: str, + current_user: Optional[dict] = Depends(get_current_username_admin), +): + user = UserService.get_user_active(username) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + deleted = UserService.delete_user(username) + logger.info("User deleted: %s", deleted) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/users/me", response_model=User) +async def read_users_me( + current_user: Annotated[User, Depends(get_current_active_user)], +): + return current_user + + +@router.get("/users/me/roles", response_model=List[str]) +async def read_users_me_roles( + current_user: Annotated[User, Depends(get_current_active_user)], +): + return current_user.roles diff --git a/app/routers/buy.py b/app/routers/buy.py index 6b21a2f..85319b8 100644 --- a/app/routers/buy.py +++ b/app/routers/buy.py @@ -1,97 +1,103 @@ -from datetime import datetime -from typing import Annotated, List - -from fastapi import APIRouter, Depends, Response, status -from fastapi.responses import HTMLResponse - -from app.auth.auth import get_current_active_user -from app.models.buy import BuyProduct, BuyProductInDB -from app.models.user import UserInDB -from app.services.buy import BuyService -from app.services.participant import ParticipantService -from app.services.product import ProductService -from app.utils.mongo_validator import PyObjectId - - -router = APIRouter() - - - -@router.post("", response_model=BuyProduct) -async def insert_buy( - buy: BuyProduct, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - buyDB = BuyProductInDB(**buy.dict()) - buyDB.username_insert = current_user.username - inserted = BuyService.insert(buyDB) - return inserted - - -@router.delete("/id/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) -async def delete_buy( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = BuyService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Buy not found" - ) - BuyService.delete(id) - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -@router.get("/id/{id}", response_model=BuyProduct) -async def get_buy( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = BuyService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Buy not found" - ) - return find - - -@router.get("", response_model=List[BuyProduct]) -async def get_all_buy( - current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = BuyService.get_all() - return finds - - -@router.get("/participant", response_model=List[BuyProduct]) -async def get_all_buy_by_participant_control_code( - participant_control_code: str, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = BuyService.get_by_participant_control_code(participant_control_code) - return finds - - - -@router.post("/participant", response_model=BuyProduct) -async def insert_buy_by_participant_control_code( - participant_control_code: str, - product_id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - participant = ParticipantService.get(participant_control_code) - if participant is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - - product = ProductService.get_enable_to_buy(product_id) - if product is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Product not found" - ) - - buyDB = BuyProductInDB( - participant_control_code=participant_control_code, - product=product.id, - points=participant.points, - username_insert=current_user.username, - date_insert=datetime.utcnow(), - ) - inserted = BuyService.insert(buyDB) - return inserted \ No newline at end of file +from datetime import datetime +from typing import Annotated, List + +from fastapi import APIRouter, Depends, Response, status +from fastapi.responses import HTMLResponse + +from app.auth.auth import get_current_active_user +from app.models.buy import BuyProduct, BuyProductInDB +from app.models.user import UserInDB +from app.services.buy import BuyService +from app.services.participant import ParticipantService +from app.services.product import ProductService +from app.utils.mongo_validator import PyObjectId + +router = APIRouter() + + +@router.post("", response_model=BuyProduct) +async def insert_buy( + buy: BuyProduct, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + buyDB = BuyProductInDB(**buy.dict()) + buyDB.username_insert = current_user.username + inserted = BuyService.insert(buyDB) + return inserted + + +@router.delete("/id/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) +async def delete_buy( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = BuyService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Buy not found", + ) + BuyService.delete(id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/id/{id}", response_model=BuyProduct) +async def get_buy( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = BuyService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Buy not found", + ) + return find + + +@router.get("", response_model=List[BuyProduct]) +async def get_all_buy( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = BuyService.get_all() + return finds + + +@router.get("/participant", response_model=List[BuyProduct]) +async def get_all_buy_by_participant_control_code( + participant_control_code: str, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = BuyService.get_by_participant_control_code(participant_control_code) + return finds + + +@router.post("/participant", response_model=BuyProduct) +async def insert_buy_by_participant_control_code( + participant_control_code: str, + product_id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + participant = ParticipantService.get(participant_control_code) + if participant is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + + product = ProductService.get_enable_to_buy(product_id) + if product is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Product not found", + ) + + buyDB = BuyProductInDB( + participant_control_code=participant_control_code, + product=product.id, + points=participant.points, + username_insert=current_user.username, + date_insert=datetime.utcnow(), + ) + inserted = BuyService.insert(buyDB) + return inserted diff --git a/app/routers/combos.py b/app/routers/combos.py index 256d232..99421a7 100644 --- a/app/routers/combos.py +++ b/app/routers/combos.py @@ -1,191 +1,207 @@ -from datetime import datetime -from typing import Annotated, List, Union -from fastapi import APIRouter, Depends, Response, status -from fastapi.responses import HTMLResponse -from app.auth.auth import get_current_active_user -from app.core.roles import Roles -from app.models.combo import ( - Combo, - ComboInDB, - RedeemCombo, - RedeemComboInDB, - RedeemComboRequest, -) - -from app.models.user import UserInDB -from app.services.combo import ComboService, RedeemComboService -from app.services.participant import ParticipantService -from app.utils.mongo_validator import PyObjectId - - -router = APIRouter() - - -# insert, delete, get, get_all -@router.post("", response_model=Combo) -async def insert_combo( - combo: Combo, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - comboDB = ComboInDB(**combo.dict()) - comboDB.username_insert = current_user.username - inserted = ComboService.insert(comboDB) - return inserted - - -@router.delete("/id/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) -async def delete_combo( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = ComboService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Combo not found" - ) - ComboService.delete(id) - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -@router.put("", response_model=Combo) -async def update_combo( - combo: Combo, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = ComboService.get(combo.id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Combo not found" - ) - comboDB = ComboInDB(**combo.dict()) - comboDB.username_update = current_user.username - updated = ComboService.update(combo.id, comboDB) - return updated - - -@router.get("/id/{id}", response_model=Combo) -async def get_combo( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = ComboService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Combo not found" - ) - return find - - -@router.get("", response_model=List[Combo]) -async def get_all_combo( - current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = ComboService.get_all() - return finds - -@router.get("/products", response_model=List[Combo]) -async def get_all_combo_and_products( - current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = ComboService.get_all_and_products() - return finds -@router.post("/products", response_model=List[Combo]) -async def get_all_combo_and_products_by_ids( - combos: List[PyObjectId], - current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = ComboService.get_all_and_products_by_ids(combos) - return finds - - -# get redeemd combos -@router.get("/redeem/user/code", response_model=List[RedeemCombo]) -async def get_redeemed_combo( - participant_control_code: str, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - participant = ParticipantService.get_by_control_code(participant_control_code) - if participant is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - finds = RedeemComboService.search_by_participant(participant_control_code) - return finds - - -@router.get("/redeem/user", response_model=List[RedeemCombo]) -async def get_all_redeemed_combo( - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - finds = RedeemComboService.get_all() - return finds - - -# redeem combos -@router.post("/redeem/user", response_model=RedeemCombo) -async def redeem_combo( - redeem: RedeemComboRequest, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - if Roles.COMBO not in current_user.roles: - return HTMLResponse( - status_code=status.HTTP_401_UNAUTHORIZED, content="User not authorized" - ) - participant = ParticipantService.get_by_control_code( - redeem.participant_control_code - ) - if participant is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - # exist combo - combo = ComboService.get(redeem.combo_id) - if combo is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Combo not found" - ) - # exist combo in participant - if redeem.combo_id not in participant.combos: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, - content="Combo not found in participant", - ) - # participant already redeemd products in combo - redeemd_products = RedeemComboService.search_by_participant_and_combo( - participant_control_code=redeem.participant_control_code, - combo_id=redeem.combo_id, - ) - # exist products - product_not_exist = [] - product_already_redeemed = [] - product_to_redeem = [] - products = list(dict.fromkeys(redeem.products_id)) - print(products) - for product_id in combo.products: - if product_id not in combo.products: - product_not_exist.append(product_id) - else: - redeemd_control = False - for redeemd_product in redeemd_products: - if product_id in redeemd_product.products: - redeemd_control = True - product_already_redeemed.append(product_id) - if not redeemd_control: - product_to_redeem.append(product_id) - message = "" - if len(product_not_exist) > 0: - message += " Products not exist in combo." - if len(product_already_redeemed) > 0: - message += " Some Products already redeemed." - if len(product_to_redeem) == 0: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="All Products already redeemed." + message, - ) - redeem_combo = RedeemComboInDB( - participant_control_code=redeem.participant_control_code, - combo=redeem.combo_id, - products=product_to_redeem, - username_insert=current_user.username, - date=datetime.now(), - user_control=current_user.username, - ) - redeemd = RedeemComboService.insert(redeem_combo) - redeemd.message += message - return redeemd +import logging +from datetime import datetime +from typing import Annotated, List + +from fastapi import APIRouter, Depends, Response, status +from fastapi.responses import HTMLResponse + +from app.auth.auth import get_current_active_user +from app.core.roles import Roles +from app.models.combo import ( + Combo, + ComboInDB, + RedeemCombo, + RedeemComboInDB, + RedeemComboRequest, +) +from app.models.user import UserInDB +from app.services.combo import ComboService, RedeemComboService +from app.services.participant import ParticipantService +from app.utils.mongo_validator import PyObjectId + +router = APIRouter() +logger = logging.getLogger(__name__) + + +# insert, delete, get, get_all +@router.post("", response_model=Combo) +async def insert_combo( + combo: Combo, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + comboDB = ComboInDB(**combo.dict()) + comboDB.username_insert = current_user.username + inserted = ComboService.insert(comboDB) + return inserted + + +@router.delete("/id/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) +async def delete_combo( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = ComboService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Combo not found", + ) + ComboService.delete(id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.put("", response_model=Combo) +async def update_combo( + combo: Combo, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = ComboService.get(combo.id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Combo not found", + ) + comboDB = ComboInDB(**combo.dict()) + comboDB.username_update = current_user.username + updated = ComboService.update(combo.id, comboDB) + return updated + + +@router.get("/id/{id}", response_model=Combo) +async def get_combo( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = ComboService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Combo not found", + ) + return find + + +@router.get("", response_model=List[Combo]) +async def get_all_combo( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = ComboService.get_all() + return finds + + +@router.get("/products", response_model=List[Combo]) +async def get_all_combo_and_products( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = ComboService.get_all_and_products() + return finds + + +@router.post("/products", response_model=List[Combo]) +async def get_all_combo_and_products_by_ids( + combos: List[PyObjectId], + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = ComboService.get_all_and_products_by_ids(combos) + return finds + + +# get redeemd combos +@router.get("/redeem/user/code", response_model=List[RedeemCombo]) +async def get_redeemed_combo( + participant_control_code: str, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + participant = ParticipantService.get_by_control_code(participant_control_code) + if participant is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + finds = RedeemComboService.search_by_participant(participant_control_code) + return finds + + +@router.get("/redeem/user", response_model=List[RedeemCombo]) +async def get_all_redeemed_combo( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = RedeemComboService.get_all() + return finds + + +# redeem combos +@router.post("/redeem/user", response_model=RedeemCombo) +async def redeem_combo( + redeem: RedeemComboRequest, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + if Roles.COMBO not in current_user.roles: + return HTMLResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content="User not authorized", + ) + participant = ParticipantService.get_by_control_code( + redeem.participant_control_code, + ) + if participant is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + # exist combo + combo = ComboService.get(redeem.combo_id) + if combo is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Combo not found", + ) + # exist combo in participant + if redeem.combo_id not in participant.combos: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Combo not found in participant", + ) + # participant already redeemd products in combo + redeemd_products = RedeemComboService.search_by_participant_and_combo( + participant_control_code=redeem.participant_control_code, + combo_id=redeem.combo_id, + ) + # exist products + product_not_exist = [] + product_already_redeemed = [] + product_to_redeem = [] + products = list(dict.fromkeys(redeem.products_id)) + logger.info("Products to redeem: %s", products) + for product_id in combo.products: + if product_id not in combo.products: + product_not_exist.append(product_id) + else: + redeemd_control = False + for redeemd_product in redeemd_products: + if product_id in redeemd_product.products: + redeemd_control = True + product_already_redeemed.append(product_id) + if not redeemd_control: + product_to_redeem.append(product_id) + message = "" + if len(product_not_exist) > 0: + message += " Products not exist in combo." + if len(product_already_redeemed) > 0: + message += " Some Products already redeemed." + if len(product_to_redeem) == 0: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="All Products already redeemed." + message, + ) + redeem_combo = RedeemComboInDB( + participant_control_code=redeem.participant_control_code, + combo=redeem.combo_id, + products=product_to_redeem, + username_insert=current_user.username, + date=datetime.now(), + user_control=current_user.username, + ) + redeemd = RedeemComboService.insert(redeem_combo) + redeemd.message += message + return redeemd diff --git a/app/routers/control.py b/app/routers/control.py index d66c048..b409b80 100644 --- a/app/routers/control.py +++ b/app/routers/control.py @@ -1,126 +1,124 @@ -from datetime import datetime -from typing import Annotated, List -from fastapi import APIRouter, Depends, status -from fastapi.responses import HTMLResponse -from app.auth.auth import get_current_active_user -from app.core.roles import Roles -from app.models.control import Control, ControlInDB - -from app.models.user import UserInDB -from app.services.control import ControlService -from app.services.location import LocationService -from app.services.participant import ParticipantService -from app.utils.mongo_validator import PyObjectId - - -router = APIRouter() - - -@router.post("", response_model=Control) -async def check( - participant_control_code: str, - location_id: PyObjectId, - check: bool, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - if Roles.CHECKER not in current_user.roles: - return HTMLResponse( - status_code=status.HTTP_401_UNAUTHORIZED, content="User not authorized" - ) - participant = ParticipantService.get_by_control_code(participant_control_code) - if participant is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - if participant == False: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Participants with same control code found", - ) - - location = LocationService.get(location_id) - if location is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Location not found" - ) - # check if participant is enable to enter in location - # if location_id not in participant.locations: - # return HTMLResponse( - # status_code=status.HTTP_400_BAD_REQUEST, - # content="Participant is not enable to enter in this location", - # ) - - # count check_in - # ins = ControlService.count_checks(participant_control_code, location_id, "in") - # outs = ControlService.count_checks(participant_control_code, location_id, "out") - count_in_and_outs_global = ControlService.count_in_and_outs_global(participant_control_code) - if count_in_and_outs_global > 0 and check: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Current check in a location", - ) - elif count_in_and_outs_global < 0 and not check: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Current check out a location", - ) - in_and_outs = ControlService.count_in_and_outs(participant_control_code, location_id) - - if in_and_outs == 0: - if check: - control = ControlInDB( - participant_control_code=participant_control_code, - location=location_id, - location_name=location.name, - control="in", - date=datetime.now(), - user_control=current_user.username, - ) - control.username_insert = current_user.username - inserted = ControlService.insert(control) - return inserted - else: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Check in first before check out", - ) - elif in_and_outs > 0: - if check: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Check in already done", - ) - else: - control = ControlInDB( - participant_control_code=participant_control_code, - location=location_id, - location_name=location.name, - control="out", - date=datetime.now(), - user_control=current_user.username, - ) - control.username_insert = current_user.username - inserted = ControlService.insert(control) - return inserted - else: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Check out inconsistent with check in", - ) - - -@router.get("", response_model=List[Control]) -async def get_all_control( - current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = ControlService.get_all() - return finds - - -@router.get("/code", response_model=List[Control]) -async def get_all_control_by_participant( - participant_control_code: str, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - finds = ControlService.get_all_by_participant(participant_control_code) - return finds +from datetime import datetime +from typing import Annotated, List + +from fastapi import APIRouter, Depends, status +from fastapi.responses import HTMLResponse + +from app.auth.auth import get_current_active_user +from app.core.roles import Roles +from app.models.control import Control, ControlInDB +from app.models.user import UserInDB +from app.services.control import ControlService +from app.services.location import LocationService +from app.services.participant import ParticipantService +from app.utils.mongo_validator import PyObjectId + +router = APIRouter() + + +@router.post("", response_model=Control) +async def check( + participant_control_code: str, + location_id: PyObjectId, + check: bool, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + if Roles.CHECKER not in current_user.roles: + return HTMLResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content="User not authorized", + ) + participant = ParticipantService.get_by_control_code(participant_control_code) + if participant is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + if not participant: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Participants with same control code found", + ) + + location = LocationService.get(location_id) + if location is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Location not found", + ) + count_in_and_outs_global = ControlService.count_in_and_outs_global( + participant_control_code, + ) + if count_in_and_outs_global > 0 and check: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Current check in a location", + ) + elif count_in_and_outs_global < 0 and not check: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Current check out a location", + ) + in_and_outs = ControlService.count_in_and_outs( + participant_control_code, + location_id, + ) + + if in_and_outs == 0: + if check: + control = ControlInDB( + participant_control_code=participant_control_code, + location=location_id, + location_name=location.name, + control="in", + date=datetime.now(), + user_control=current_user.username, + ) + control.username_insert = current_user.username + inserted = ControlService.insert(control) + return inserted + else: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Check in first before check out", + ) + elif in_and_outs > 0: + if check: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Check in already done", + ) + else: + control = ControlInDB( + participant_control_code=participant_control_code, + location=location_id, + location_name=location.name, + control="out", + date=datetime.now(), + user_control=current_user.username, + ) + control.username_insert = current_user.username + inserted = ControlService.insert(control) + return inserted + else: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Check out inconsistent with check in", + ) + + +@router.get("", response_model=List[Control]) +async def get_all_control( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = ControlService.get_all() + return finds + + +@router.get("/code", response_model=List[Control]) +async def get_all_control_by_participant( + participant_control_code: str, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = ControlService.get_all_by_participant(participant_control_code) + return finds diff --git a/app/routers/gifts.py b/app/routers/gifts.py index e2f3067..13921e6 100644 --- a/app/routers/gifts.py +++ b/app/routers/gifts.py @@ -1,131 +1,121 @@ -from datetime import datetime -from typing import Annotated, List -from fastapi import APIRouter, Depends, Response, status -from fastapi.responses import HTMLResponse -from app.auth.auth import get_current_active_user -from app.core.roles import Roles -from app.models.gift import Gift, GiftInDB, RedeemGift, RedeemGiftInDB - -from app.models.user import UserInDB -from app.services.gift import GiftService, RedeemGiftService -from app.services.participant import ParticipantService -from app.utils.mongo_validator import PyObjectId - - -router = APIRouter() - - -# insert, delete, get, get_all -@router.post("", response_model=Gift) -async def insert_gift( - gift: Gift, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - giftDB = GiftInDB(**gift.dict()) - giftDB.username_insert = current_user.username - inserted = GiftService.insert(giftDB) - return inserted - - -@router.delete("/id/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) -async def delete_gift( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = GiftService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Gift not found" - ) - GiftService.delete(id) - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -@router.get("/id/{id}", response_model=Gift) -async def get_gift( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = GiftService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Gift not found" - ) - return find - - -@router.get("", response_model=List[Gift]) -async def get_all_gift( - current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = GiftService.get_all() - return finds - -@router.get("/products", response_model=List[Gift]) -async def get_all_gift_and_products( - current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = GiftService.get_all_gift_and_products() - return finds - - -@router.get("/redeem/user", response_model=List[RedeemGift]) -async def get_all_redeem_gift( - current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = RedeemGiftService.get_all() - return finds - - -@router.get("/redeem/user/code", response_model=List[RedeemGift]) -async def get_all_redeem_gift_by_participant( - participant_control_code: str, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - finds = RedeemGiftService.get_all_by_participant(participant_control_code) - return finds - - -@router.post("/redeem/user", response_model=RedeemGift) -async def redeem_point( - participant_control_code: str, - gift_id: PyObjectId, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - if Roles.SWAG not in current_user.roles: - return HTMLResponse( - status_code=status.HTTP_401_UNAUTHORIZED, content="User not authorized" - ) - participant = ParticipantService.get_by_control_code(participant_control_code) - if participant is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - # search gift - gift = GiftService.get(gift_id) - if gift is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Gift not found" - ) - if not gift.multiple: - already = RedeemGiftService.get_redeem_gift_by_participant(participant_control_code, gift_id) - if len(already) > 0: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, content="Gift already redeemed" - ) - - # check if participant has enough points - if participant.points < gift.points: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, content=f"Not enough points, participant has {participant.points} points" - ) - # subtract points to participant - participant = ParticipantService.subtract_points(participant.id, gift.points) - - # insert redeem gift - redeem = RedeemGiftInDB( - participant_control_code=participant_control_code, - gift=gift_id, - date=datetime.now(), - user_control=current_user.username, - username_insert=current_user.username, - ) - redeem = RedeemGiftService.insert(redeem) - return redeem +from typing import Annotated, List + +from fastapi import APIRouter, BackgroundTasks, Depends, Response, status +from fastapi.responses import HTMLResponse + +from app.auth.auth import get_current_active_user +from app.core.roles import Roles +from app.models.gift import Gift, GiftInDB, RedeemGift +from app.models.user import UserInDB +from app.services.gift import GiftService, RedeemGiftService +from app.services.participant import ParticipantService +from app.services.redemption import RedemptionError, redeem_gift, send_redeem_email +from app.utils.mongo_validator import PyObjectId + +router = APIRouter() + + +# insert, delete, get, get_all +@router.post("", response_model=Gift) +async def insert_gift( + gift: Gift, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + giftDB = GiftInDB(**gift.dict()) + giftDB.username_insert = current_user.username + inserted = GiftService.insert(giftDB) + return inserted + + +@router.delete("/id/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) +async def delete_gift( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = GiftService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Gift not found", + ) + GiftService.delete(id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/id/{id}", response_model=Gift) +async def get_gift( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = GiftService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Gift not found", + ) + return find + + +@router.get("", response_model=List[Gift]) +async def get_all_gift( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = GiftService.get_all() + return finds + + +@router.get("/products", response_model=List[Gift]) +async def get_all_gift_and_products( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = GiftService.get_all_gift_and_products() + return finds + + +@router.get("/redeem/user", response_model=List[RedeemGift]) +async def get_all_redeem_gift( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = RedeemGiftService.get_all() + return finds + + +@router.get("/redeem/user/code", response_model=List[RedeemGift]) +async def get_all_redeem_gift_by_participant( + participant_control_code: str, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = RedeemGiftService.get_all_by_participant(participant_control_code) + return finds + + +@router.post("/redeem/user", response_model=RedeemGift) +async def redeem_point( + participant_control_code: str, + gift_id: PyObjectId, + background_tasks: BackgroundTasks, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + if Roles.SWAG not in current_user.roles: + return HTMLResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content="User not authorized", + ) + participant = ParticipantService.get_by_control_code(participant_control_code) + if participant is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + try: + redeem, product = redeem_gift(participant, gift_id, current_user.username) + except RedemptionError as error: + return HTMLResponse(status_code=error.status_code, content=error.message) + background_tasks.add_task( + send_redeem_email, + participant, + product, + redeem.discount_code, + redeem.download_url, + ) + return redeem diff --git a/app/routers/locations.py b/app/routers/locations.py index 3e3e4d9..69686a9 100644 --- a/app/routers/locations.py +++ b/app/routers/locations.py @@ -1,57 +1,61 @@ -from typing import Annotated, List -from fastapi import APIRouter, Depends, Response, status -from fastapi.responses import HTMLResponse -from app.auth.auth import get_current_active_user -from app.models.location import Location, LocationInDB - -from app.models.user import UserInDB -from app.services.location import LocationService -from app.utils.mongo_validator import PyObjectId - - -router = APIRouter() - - -# insert, delete, get, get_all -@router.post("", response_model=Location) -async def insert_location( - location: Location, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - locationDB = LocationInDB(**location.dict()) - locationDB.username_insert = current_user.username - inserted = LocationService.insert(locationDB) - return inserted - - -@router.delete("/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) -async def delete_location( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = LocationService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Location not found" - ) - LocationService.delete(id) - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -@router.get("/{id}", response_model=Location) -async def get_location( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = LocationService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Location not found" - ) - return find - - -@router.get("", response_model=List[Location]) -async def get_all_location( - current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = LocationService.get_all() - return finds +from typing import Annotated, List + +from fastapi import APIRouter, Depends, Response, status +from fastapi.responses import HTMLResponse + +from app.auth.auth import get_current_active_user +from app.models.location import Location, LocationInDB +from app.models.user import UserInDB +from app.services.location import LocationService +from app.utils.mongo_validator import PyObjectId + +router = APIRouter() + + +# insert, delete, get, get_all +@router.post("", response_model=Location) +async def insert_location( + location: Location, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + locationDB = LocationInDB(**location.dict()) + locationDB.username_insert = current_user.username + inserted = LocationService.insert(locationDB) + return inserted + + +@router.delete("/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) +async def delete_location( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = LocationService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Location not found", + ) + LocationService.delete(id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/{id}", response_model=Location) +async def get_location( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = LocationService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Location not found", + ) + return find + + +@router.get("", response_model=List[Location]) +async def get_all_location( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = LocationService.get_all() + return finds diff --git a/app/routers/meals.py b/app/routers/meals.py index 65c795c..63ed9be 100644 --- a/app/routers/meals.py +++ b/app/routers/meals.py @@ -1,104 +1,111 @@ -from datetime import datetime -from typing import Annotated, List -from fastapi import APIRouter, Depends, Response, status -from fastapi.responses import HTMLResponse -from app.auth.auth import get_current_active_user -from app.core.roles import Roles -from app.models.meal import RedeemMeal, RedeemMealInDB - -from app.models.user import UserInDB -from app.services.meal import RedeemMealService -from app.services.participant import ParticipantService -from app.utils.mongo_validator import PyObjectId - - -router = APIRouter() - - -@router.post("", response_model=RedeemMeal) -async def insert_redeem_meal( - redeem_meal: RedeemMeal, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - redeem_mealDB = RedeemMealInDB(**redeem_meal.dict()) - redeem_mealDB.username_insert = current_user.username - inserted = RedeemMealService.insert(redeem_mealDB) - return inserted - - -@router.delete("/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) -async def delete_redeem_meal( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = RedeemMealService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="RedeemMeal not found" - ) - RedeemMealService.delete(id) - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -@router.get("/{id}", response_model=RedeemMeal) -async def get_redeem_meal( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = RedeemMealService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="RedeemMeal not found" - ) - return find - - -@router.get("/redeem/user", response_model=List[RedeemMeal]) -async def get_all_redeem_meal( - current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = RedeemMealService.get_all() - return finds - - -@router.get("/redeem/user/code", response_model=List[RedeemMeal]) -async def get_all_meal_by_participant( - participant_control_code: str, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - # search participant - participant = ParticipantService.get_by_control_code(participant_control_code) - if participant is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - finds = RedeemMealService.get_by_participant(participant_control_code) - return finds - - -@router.post("/redeem/user", response_model=RedeemMeal) -async def redeem_meal( - participant_control_code: str, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - if Roles.LAUNCH not in current_user.roles: - return HTMLResponse( - status_code=status.HTTP_401_UNAUTHORIZED, content="User not authorized" - ) - participant = ParticipantService.get_by_control_code(participant_control_code) - if participant is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - finds = RedeemMealService.get_by_participant(participant_control_code) - if len(finds) > 0: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Participant already redeemed meal", - ) - redeem_meal = RedeemMealInDB( - participant_control_code=participant_control_code, - date=datetime.now(), - user_control=current_user.username, - username_insert=current_user.username, - ) - inserted = RedeemMealService.insert(redeem_meal) - return inserted +from datetime import datetime +from typing import Annotated, List + +from fastapi import APIRouter, Depends, Response, status +from fastapi.responses import HTMLResponse + +from app.auth.auth import get_current_active_user +from app.core.roles import Roles +from app.models.meal import RedeemMeal, RedeemMealInDB +from app.models.user import UserInDB +from app.services.meal import RedeemMealService +from app.services.participant import ParticipantService +from app.utils.mongo_validator import PyObjectId + +router = APIRouter() + + +@router.post("", response_model=RedeemMeal) +async def insert_redeem_meal( + redeem_meal: RedeemMeal, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + redeem_mealDB = RedeemMealInDB(**redeem_meal.dict()) + redeem_mealDB.username_insert = current_user.username + inserted = RedeemMealService.insert(redeem_mealDB) + return inserted + + +@router.delete("/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) +async def delete_redeem_meal( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = RedeemMealService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="RedeemMeal not found", + ) + RedeemMealService.delete(id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/{id}", response_model=RedeemMeal) +async def get_redeem_meal( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = RedeemMealService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="RedeemMeal not found", + ) + return find + + +@router.get("/redeem/user", response_model=List[RedeemMeal]) +async def get_all_redeem_meal( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = RedeemMealService.get_all() + return finds + + +@router.get("/redeem/user/code", response_model=List[RedeemMeal]) +async def get_all_meal_by_participant( + participant_control_code: str, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + # search participant + participant = ParticipantService.get_by_control_code(participant_control_code) + if participant is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + finds = RedeemMealService.get_by_participant(participant_control_code) + return finds + + +@router.post("/redeem/user", response_model=RedeemMeal) +async def redeem_meal( + participant_control_code: str, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + if Roles.LAUNCH not in current_user.roles: + return HTMLResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content="User not authorized", + ) + participant = ParticipantService.get_by_control_code(participant_control_code) + if participant is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + finds = RedeemMealService.get_by_participant(participant_control_code) + if len(finds) > 0: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Participant already redeemed meal", + ) + redeem_meal = RedeemMealInDB( + participant_control_code=participant_control_code, + date=datetime.now(), + user_control=current_user.username, + username_insert=current_user.username, + ) + inserted = RedeemMealService.insert(redeem_meal) + return inserted diff --git a/app/routers/participants.py b/app/routers/participants.py index e6f9683..f601d74 100644 --- a/app/routers/participants.py +++ b/app/routers/participants.py @@ -1,366 +1,417 @@ -import json -import requests -import csv -from datetime import datetime -from typing import Annotated, List, Optional -from fastapi import APIRouter, Depends, Response, UploadFile, status -from fastapi.responses import HTMLResponse -from app.auth.auth import get_current_active_user, get_current_username_admin -from app.models.participant import Participant, ParticipantInDB - -from app.models.user import UserInDB -from app.services.buy import BuyService -from app.services.combo import ComboService, RedeemComboService -from app.services.control import ControlService -from app.services.gift import RedeemGiftService -from app.services.meal import RedeemMealService -from app.services.participant import ParticipantService -from app.services.point import PointService -from app.services.special_point import SpecialPointService -from app.utils.mongo_validator import PyObjectId -from app.core.roles import Roles -from app.core.configuration import APP_USERS_API_URL, APP_USERS_API_TOKEN -router = APIRouter() - - -# insert, delete, get, get_all -@router.post("", response_model=Participant) -async def insert_participant( - participant: Participant, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - participantDB = ParticipantInDB(**participant.dict()) - participantDB.username_insert = current_user.username - inserted = ParticipantService.insert(participantDB) - return inserted - - -@router.delete("/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) -async def delete_participant( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = ParticipantService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - ParticipantService.delete(id) - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -@router.get("/{id}", response_model=Participant) -async def get_participant( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = ParticipantService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - return find - - -@router.get("", response_model=List[Participant]) -async def get_all_participant( - current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = ParticipantService.get_all() - return finds - -@router.put("/{id}", response_model=Participant) -async def update_participant( - id: PyObjectId, - participant: Participant, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - find = ParticipantService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - participantDB = ParticipantInDB(**participant.dict()) - participantDB.username_update = current_user.username - updated = ParticipantService.update(id, participantDB) - return updated - -@router.post("/registration", response_model=Participant) -async def register_participant( - ci: str, - control_code: str, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - if Roles.ACCREDITATION not in current_user.roles: - return HTMLResponse( - status_code=status.HTTP_401_UNAUTHORIZED, content="User not authorized" - ) - find = ParticipantService.get_by_ci(ci) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - if find.registration: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Participant already registered", - ) - # search other use with the same control code - exist = ParticipantService.exist_control_code(control_code) - - if exist: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Control code already exist in other user", - ) - - finish = ParticipantService.finish_registration(find.id, control_code) - return finish - -@router.post("/unregistration", response_model=Participant) -async def unregister_participant( - control_code: str, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - if Roles.REVOCATION not in current_user.roles: - return HTMLResponse( - status_code=status.HTTP_401_UNAUTHORIZED, content="User not authorized" - ) - find = ParticipantService.get_by_control_code(control_code) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - # search activity check in and checkouts - points = PointService.exist_participant_control_code(control_code) - if points: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Participant has points", - ) - buys = BuyService.exist_participant_control_code(control_code) - if buys: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Participant has buys", - ) - combos = RedeemComboService.exist_participant_control_code(control_code) - if combos: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Participant has combos", - ) - control = ControlService.exist_participant_control_code(control_code) - if control: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Participant has controls", - ) - gifts = RedeemGiftService.exist_participant_control_code(control_code) - if gifts: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Participant has redeemed gifts", - ) - meals = RedeemMealService.exist_participant_control_code(control_code) - if meals: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Participant has redeemed meals", - ) - special = SpecialPointService.exist_participant_control_code(control_code) - if special: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Participant has special points", - ) - - finish = ParticipantService.finish_unregistration(find.id, current_user.username) - if finish is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - save_revoke = ParticipantService.save_revoke(finish.id, control_code, find.ci, current_user.username) - return finish - - -@router.get("/user/code", response_model=Participant) -async def get_participant_control_code( - participant_control_code: str, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - if Roles.INFORMATION not in current_user.roles: - return HTMLResponse( - status_code=status.HTTP_401_UNAUTHORIZED, content="User not authorized" - ) - find = ParticipantService.get_by_control_code_full(participant_control_code) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - if find is False: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Multiple participant with same control code found", - ) - return find - -@router.get("/user/code/ci", response_model=Participant) -async def get_participant_control_code( - ci: str, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - if Roles.INFORMATION not in current_user.roles: - return HTMLResponse( - status_code=status.HTTP_401_UNAUTHORIZED, content="User not authorized" - ) - find = ParticipantService.get_by_control_ci(ci) - if not find: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - if not find.control_code: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Participant not registered", - ) - find = ParticipantService.get_by_control_code_full(find.control_code) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - if find is False: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Multiple participant with same control code found", - ) - return find - - -@router.get("/user/ci", response_model=Participant) -async def get_participant_ci( - ci: str, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = ParticipantService.get_by_control_ci(ci) - if find is None: - headers = { - "Authorization" : APP_USERS_API_TOKEN, - "Content-Type": "application/json" - } - response = requests.post(APP_USERS_API_URL + f'/{ci}', headers=headers) - if response.status_code == 200: - data = response.json() - parti = ParticipantInDB( - mail=data['email'], - name=data['given_name'] + ' ' + data['family_name'], - #'birthday': '2003-06-17T04:00:00' - birthday=datetime.strptime(data['birthday'].split('T')[0], "%Y-%m-%d"), - gender=data["gender"], - ci=data["dni"], - profession=data["profession"], - work_place=data["place_profession"], - residence_place=data["residence"], - photo=data["picture"], - phone=data["phone"], - t_shirt_size="", - food_restrictions="", - ) - parti.id = PyObjectId(data['_id']) - ids = ParticipantService.insert_many([parti],delete_id=False) - find = ParticipantService.get_by_control_ci(ci) - else: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - if find is False: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Multiple Participants with same ci found", - ) - return find - -#import file csv with participants -@router.post("/import") -async def upload_file(file: UploadFile, current_user: Optional[dict] = Depends(get_current_username_admin)): - # Check if the uploaded file is a CSV - if file.filename.endswith('.csv'): - # Read the CSV file and print the content of the cells - csv_data = file.file.read().decode("utf-8") - reader = csv.reader(csv_data.splitlines()) - list_participants = [] - for row in reader: - #split row 12 using ; - row_12 = row[12].split(";") - row_13 = row[13].split(";") - # print (row_12,row_13) - location_id = [] - for i in range(len(row_12)): - if row_12[i] != "": - id = PyObjectId(row_12[i]) - location_id.append(id) - # print(location_id) - combos_id = [] - for i in range(len(row_13)): - if row_13[i] != "": - id = PyObjectId(row_13[i]) - combos_id.append(id) - # print(combos_id) - list_participants.append(ParticipantInDB( - mail=row[0], - name=row[1], - birthday=datetime.strptime(row[2], "%d/%m/%Y" ), - gender=row[3], - ci=row[4], - profession=row[5], - work_place=row[6], - residence_place=row[7], - photo=row[8], - phone=row[9], - t_shirt_size=row[10], - food_restrictions=row[11], - locations=location_id, - combos=combos_id, - )) - ids = ParticipantService.insert_many(list_participants) - print(ids) - - return {"message": "File uploaded and data printed successfully."} - - else: - return {"error": "Only CSV files are supported."} - -@router.post("/migration") -async def migrate(current_user: Optional[dict] = Depends(get_current_username_admin)): - #Consume API - headers = { - 'Authorization': APP_USERS_API_TOKEN, - 'Content-Type': 'application/json' - } - actual_ids = ParticipantService.get_all_ids() - payload = json.dumps(actual_ids) - response = requests.post(APP_USERS_API_URL, headers=headers , data=payload) - data = response.json() - participants = data['participants'] - list_participants = [] - for participant in participants: - parti = ParticipantInDB( - id=PyObjectId(participant['_id']), - mail=participant['email'], - name=participant['given_name'] + ' ' + participant['family_name'], - #'birthday': '2003-06-17T04:00:00' - birthday=datetime.strptime(participant['birthday'].split('T')[0], "%Y-%m-%d"), - gender=participant['gender'], - ci=participant['dni'], - profession=participant['profession'], - work_place=participant['place_profession'], - residence_place=participant['residence'], - photo=participant['picture'], - phone=participant['phone'], - t_shirt_size="", - food_restrictions="", - ) - parti.id = PyObjectId(participant['_id']) - list_participants.append(parti) - if list_participants: - ids = ParticipantService.insert_many(list_participants,delete_id=False) - return True - else: - return False \ No newline at end of file +import csv +import json +import logging +from datetime import datetime +from typing import Annotated, List, Optional + +import requests +from fastapi import APIRouter, Depends, Response, UploadFile, status +from fastapi.responses import HTMLResponse + +from app.auth.auth import get_current_active_user, get_current_username_admin +from app.core.configuration import APP_USERS_API_TOKEN, APP_USERS_API_URL +from app.core.roles import Roles +from app.models.participant import Participant, ParticipantInDB +from app.models.user import UserInDB +from app.services.buy import BuyService +from app.services.combo import RedeemComboService +from app.services.control import ControlService +from app.services.gift import RedeemGiftService +from app.services.meal import RedeemMealService +from app.services.participant import ParticipantService +from app.services.point import PointService +from app.services.special_point import SpecialPointService +from app.utils.mongo_validator import PyObjectId + +router = APIRouter() +logger = logging.getLogger(__name__) + + +# insert, delete, get, get_all +@router.post("", response_model=Participant) +async def insert_participant( + participant: Participant, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + participantDB = ParticipantInDB(**participant.dict()) + participantDB.username_insert = current_user.username + participantDB.birthday = participantDB.birthday.replace( + hour=0, + minute=0, + second=0, + microsecond=0, + tzinfo=None, + ) + inserted = ParticipantService.insert(participantDB) + return inserted + + +@router.delete("/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) +async def delete_participant( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = ParticipantService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + ParticipantService.delete(id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/{id}", response_model=Participant) +async def get_participant( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = ParticipantService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + return find + + +@router.get("", response_model=List[Participant]) +async def get_all_participant( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = ParticipantService.get_all() + return finds + + +@router.put("/{id}", response_model=Participant) +async def update_participant( + id: PyObjectId, + participant: Participant, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = ParticipantService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + participantDB = ParticipantInDB(**participant.dict()) + participantDB.username_update = current_user.username + participantDB.birthday = participantDB.birthday.replace( + hour=0, + minute=0, + second=0, + microsecond=0, + tzinfo=None, + ) + updated = ParticipantService.update(id, participantDB) + return updated + + +@router.post("/registration", response_model=Participant) +async def register_participant( + ci: str, + control_code: str, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + if Roles.ACCREDITATION not in current_user.roles: + return HTMLResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content="User not authorized", + ) + find = ParticipantService.get_by_ci(ci) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + if find.registration: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Participant already registered", + ) + # search other use with the same control code + exist = ParticipantService.exist_control_code(control_code) + + if exist: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Control code already exist in other user", + ) + + finish = ParticipantService.finish_registration(find.id, control_code) + return finish + + +@router.post("/unregistration", response_model=Participant) +async def unregister_participant( + control_code: str, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + if Roles.REVOCATION not in current_user.roles: + return HTMLResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content="User not authorized", + ) + find = ParticipantService.get_by_control_code(control_code) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + # search activity check in and checkouts + points = PointService.exist_participant_control_code(control_code) + if points: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Participant has points", + ) + buys = BuyService.exist_participant_control_code(control_code) + if buys: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Participant has buys", + ) + combos = RedeemComboService.exist_participant_control_code(control_code) + if combos: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Participant has combos", + ) + control = ControlService.exist_participant_control_code(control_code) + if control: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Participant has controls", + ) + gifts = RedeemGiftService.exist_participant_control_code(control_code) + if gifts: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Participant has redeemed gifts", + ) + meals = RedeemMealService.exist_participant_control_code(control_code) + if meals: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Participant has redeemed meals", + ) + special = SpecialPointService.exist_participant_control_code(control_code) + if special: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Participant has special points", + ) + + finish = ParticipantService.finish_unregistration(find.id, current_user.username) + if finish is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + save_revoke = ParticipantService.save_revoke( + finish.id, + control_code, + find.ci, + current_user.username, + ) + logger.info("Participant unregistration finished: %s", save_revoke) + return finish + + +@router.get("/user/code", response_model=Participant) +async def get_participant_control_code( + participant_control_code: str, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + if Roles.INFORMATION not in current_user.roles: + return HTMLResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content="User not authorized", + ) + find = ParticipantService.get_by_control_code_full(participant_control_code) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + if find is False: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Multiple participant with same control code found", + ) + return find + + +@router.get("/user/code/ci", response_model=Participant) +async def get_participant_control_code_ci( + ci: str, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + if Roles.INFORMATION not in current_user.roles: + return HTMLResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content="User not authorized", + ) + find = ParticipantService.get_by_control_ci(ci) + if not find: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + if not find.control_code: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Participant not registered", + ) + find = ParticipantService.get_by_control_code_full(find.control_code) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + if find is False: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Multiple participant with same control code found", + ) + return find + + +@router.get("/user/ci", response_model=Participant) +async def get_participant_ci( + ci: str, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = ParticipantService.get_by_control_ci(ci) + if find is None: + headers = { + "Authorization": APP_USERS_API_TOKEN, + "Content-Type": "application/json", + } + response = requests.post(APP_USERS_API_URL + f"/{ci}", headers=headers) + if response.status_code == 200: + data = response.json() + parti = ParticipantInDB( + mail=data["email"], + name=data["given_name"] + " " + data["family_name"], + #'birthday': '2003-06-17T04:00:00' + birthday=datetime.strptime(data["birthday"].split("T")[0], "%Y-%m-%d"), + gender=data["gender"], + ci=data["dni"], + profession=data["profession"], + work_place=data["place_profession"], + residence_place=data["residence"], + photo=data["picture"], + phone=data["phone"], + t_shirt_size="", + food_restrictions="", + ) + parti.id = PyObjectId(data["_id"]) + ids = ParticipantService.insert_many([parti], delete_id=False) + logger.info("Participant imported with ID: %s", ids) + find = ParticipantService.get_by_control_ci(ci) + else: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + if find is False: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Multiple Participants with same ci found", + ) + return find + + +# import file csv with participants +@router.post("/import") +async def upload_file( + file: UploadFile, + current_user: Optional[dict] = Depends(get_current_username_admin), +): + # Check if the uploaded file is a CSV + if file.filename.endswith(".csv"): + # Read the CSV file and print the content of the cells + csv_data = file.file.read().decode("utf-8") + reader = csv.reader(csv_data.splitlines()) + list_participants = [] + for row in reader: + # split row 12 using ; + row_12 = row[12].split(";") + row_13 = row[13].split(";") + location_id = [] + for i in range(len(row_12)): + if row_12[i] != "": + id = PyObjectId(row_12[i]) + location_id.append(id) + combos_id = [] + for i in range(len(row_13)): + if row_13[i] != "": + id = PyObjectId(row_13[i]) + combos_id.append(id) + list_participants.append( + ParticipantInDB( + mail=row[0], + name=row[1], + birthday=datetime.strptime(row[2], "%d/%m/%Y"), + gender=row[3], + ci=row[4], + profession=row[5], + work_place=row[6], + residence_place=row[7], + photo=row[8], + phone=row[9], + t_shirt_size=row[10], + food_restrictions=row[11], + locations=location_id, + combos=combos_id, + ), + ) + ids = ParticipantService.insert_many(list_participants) + logger.info("Participants imported with IDs: %s", ids) + + return {"message": "File uploaded and data printed successfully."} + + else: + return {"error": "Only CSV files are supported."} + + +@router.post("/migration") +async def migrate(current_user: Optional[dict] = Depends(get_current_username_admin)): + # Consume API + headers = {"Authorization": APP_USERS_API_TOKEN, "Content-Type": "application/json"} + actual_ids = ParticipantService.get_all_ids() + payload = json.dumps(actual_ids) + response = requests.post(APP_USERS_API_URL, headers=headers, data=payload) + data = response.json() + participants = data["participants"] + list_participants = [] + for participant in participants: + parti = ParticipantInDB( + id=PyObjectId(participant["_id"]), + mail=participant["email"], + name=participant["given_name"] + " " + participant["family_name"], + #'birthday': '2003-06-17T04:00:00' + birthday=datetime.strptime( + participant["birthday"].split("T")[0], + "%Y-%m-%d", + ), + gender=participant["gender"], + ci=participant["dni"], + profession=participant["profession"], + work_place=participant["place_profession"], + residence_place=participant["residence"], + photo=participant["picture"], + phone=participant["phone"], + t_shirt_size="", + food_restrictions="", + ) + parti.id = PyObjectId(participant["_id"]) + list_participants.append(parti) + if list_participants: + ids = ParticipantService.insert_many(list_participants, delete_id=False) + logger.info("Participants migrated with IDs: %s", ids) + return True + else: + return False diff --git a/app/routers/points.py b/app/routers/points.py index 97cbbe3..2bd472e 100644 --- a/app/routers/points.py +++ b/app/routers/points.py @@ -1,194 +1,186 @@ -from datetime import datetime -from typing import Annotated, List -from fastapi import APIRouter, Depends, Response, status -from fastapi.responses import HTMLResponse -from app.auth.auth import get_current_active_user -from app.core.roles import Roles -from app.models.control import ControlInDB -from app.models.point import Point, PointInDB - -from app.models.user import UserInDB -from app.services.control import ControlService -from app.services.location import LocationService -from app.services.participant import ParticipantService -from app.services.point import PointService -from app.services.presentation import PresentationService -from app.utils.mongo_validator import PyObjectId - - -router = APIRouter() - - -# insert, delete, get, get_all -@router.post("", response_model=Point) -async def insert_point( - point: Point, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - pointDB = PointInDB(**point.dict()) - pointDB.username_insert = current_user.username - inserted = PointService.insert(pointDB) - return inserted - - -@router.delete("/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) -async def delete_point( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = PointService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Point not found" - ) - PointService.delete(id) - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -@router.get("/{id}", response_model=Point) -async def get_point( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = PointService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Point not found" - ) - return find - - -@router.get("", response_model=List[Point]) -async def get_all_point( - current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = PointService.get_all() - return finds - - -# redeem points -@router.post("/obtain", response_model=Point) -async def redeem_point( - participant_control_code: str, - presentation_id: PyObjectId, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - if Roles.CHECKER not in current_user.roles: - return HTMLResponse( - status_code=status.HTTP_401_UNAUTHORIZED, content="User not authorized" - ) - # search participant - participant = ParticipantService.get_by_control_code(participant_control_code) - if participant is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - # search presentation - presentation = PresentationService.get(presentation_id) - if presentation is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Presentation not found" - ) - # participant enable to location - # if presentation.location not in participant.locations: - # return HTMLResponse( - # status_code=status.HTTP_400_BAD_REQUEST, - # content="Participant not enable to this presentation", - # ) - # actual checkin location - in_and_outs = ControlService.count_in_and_outs(participant_control_code, presentation.location) - if in_and_outs <= 0: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Participant not checkin in this location", - ) - # if is workshop make checkout - # if presentation.kind == "workshop": - # control = ControlInDB( - # participant_control_code=participant_control_code, - # location=presentation.location, - # location_name="AUTO: "+presentation.name, - # control="out", - # date=datetime.now(), - # user_control=current_user.username, - # ) - # control.username_insert = current_user.username - # inserted = ControlService.insert(control) - - # already change points in this presentation - points = PointService.get_by_participant_and_presentation( - participant_control_code, presentation_id - ) - if points is not None: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Participant already change points in this presentation", - ) - - # add points to participant - participant = ParticipantService.add_points(participant.id, presentation.points) - - # insert point - point = PointInDB( - participant_control_code=participant_control_code, - presentation=presentation_id, - points=presentation.points, - date=datetime.now(), - user_control=current_user.username, - ) - inserted = PointService.insert(point) - return inserted - -#reedem points participation only talks no workshops -@router.post("/participation", response_model=Point) -async def redeem_point_participation( - participant_control_code: str, - presentation_id: PyObjectId, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - if Roles.CHECKER not in current_user.roles: - return HTMLResponse( - status_code=status.HTTP_401_UNAUTHORIZED, content="User not authorized" - ) - participant = ParticipantService.get_by_control_code(participant_control_code) - if participant is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - # search presentation - presentation = PresentationService.get(presentation_id) - if presentation is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Presentation not found" - ) - # validate only talk - if presentation.kind == "workshop": - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Participation only for talks", - ) - # participant enable to location - # if presentation.location not in participant.locations: - # return HTMLResponse( - # status_code=status.HTTP_400_BAD_REQUEST, - # content="Participant not enable to this presentation", - # ) - # actual checkin location - in_and_outs = ControlService.count_in_and_outs(participant_control_code, presentation.location) - if in_and_outs <= 0: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content="Participant not checkin in this presentation", - ) - - # add points to participant - participant = ParticipantService.add_points(participant.id, presentation.participation_points) - - # insert point - point = PointInDB( - participant_control_code=participant_control_code, - presentation=presentation_id, - points=presentation.participation_points, - date=datetime.now(), - user_control=current_user.username, - is_participation=True - ) - inserted = PointService.insert(point) - return inserted \ No newline at end of file +from datetime import datetime +from typing import Annotated, List + +from fastapi import APIRouter, Depends, Response, status +from fastapi.responses import HTMLResponse + +from app.auth.auth import get_current_active_user +from app.core.roles import Roles +from app.models.point import Point, PointInDB +from app.models.user import UserInDB +from app.services.control import ControlService +from app.services.participant import ParticipantService +from app.services.point import PointService +from app.services.presentation import PresentationService +from app.utils.mongo_validator import PyObjectId + +router = APIRouter() + + +# insert, delete, get, get_all +@router.post("", response_model=Point) +async def insert_point( + point: Point, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + pointDB = PointInDB(**point.dict()) + pointDB.username_insert = current_user.username + inserted = PointService.insert(pointDB) + return inserted + + +@router.delete("/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) +async def delete_point( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = PointService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Point not found", + ) + PointService.delete(id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/{id}", response_model=Point) +async def get_point( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = PointService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Point not found", + ) + return find + + +@router.get("", response_model=List[Point]) +async def get_all_point( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = PointService.get_all() + return finds + + +# redeem points +@router.post("/obtain", response_model=Point) +async def redeem_point( + participant_control_code: str, + presentation_id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + if Roles.CHECKER not in current_user.roles: + return HTMLResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content="User not authorized", + ) + # search participant + participant = ParticipantService.get_by_control_code(participant_control_code) + if participant is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + # search presentation + presentation = PresentationService.get(presentation_id) + if presentation is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Presentation not found", + ) + in_and_outs = ControlService.count_in_and_outs( + participant_control_code, + presentation.location, + ) + if in_and_outs <= 0: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Participant not checkin in this location", + ) + points = PointService.get_by_participant_and_presentation( + participant_control_code, + presentation_id, + ) + if points is not None: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Participant already change points in this presentation", + ) + + # add points to participant + participant = ParticipantService.add_points(participant.id, presentation.points) + + # insert point + point = PointInDB( + participant_control_code=participant_control_code, + presentation=presentation_id, + points=presentation.points, + date=datetime.now(), + user_control=current_user.username, + ) + inserted = PointService.insert(point) + return inserted + + +# reedem points participation only talks no workshops +@router.post("/participation", response_model=Point) +async def redeem_point_participation( + participant_control_code: str, + presentation_id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + if Roles.CHECKER not in current_user.roles: + return HTMLResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content="User not authorized", + ) + participant = ParticipantService.get_by_control_code(participant_control_code) + if participant is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + # search presentation + presentation = PresentationService.get(presentation_id) + if presentation is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Presentation not found", + ) + # validate only talk + if presentation.kind == "workshop": + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Participation only for talks", + ) + in_and_outs = ControlService.count_in_and_outs( + participant_control_code, + presentation.location, + ) + if in_and_outs <= 0: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Participant not checkin in this presentation", + ) + + # add points to participant + participant = ParticipantService.add_points( + participant.id, + presentation.participation_points, + ) + + # insert point + point = PointInDB( + participant_control_code=participant_control_code, + presentation=presentation_id, + points=presentation.participation_points, + date=datetime.now(), + user_control=current_user.username, + is_participation=True, + ) + inserted = PointService.insert(point) + return inserted diff --git a/app/routers/presentation_checkpoint_links.py b/app/routers/presentation_checkpoint_links.py new file mode 100644 index 0000000..30337be --- /dev/null +++ b/app/routers/presentation_checkpoint_links.py @@ -0,0 +1,145 @@ +from datetime import datetime +from typing import Annotated, List + +from fastapi import APIRouter, Depends, Response, status +from fastapi.responses import HTMLResponse + +from app.auth.auth import get_current_active_user +from app.models.presentation_checkpoint_link import ( + PresentationCheckpointLink, + PresentationCheckpointLinkInDB, +) +from app.models.user import UserInDB +from app.services.presentation import PresentationService +from app.services.presentation_checkpoint_link import PresentationCheckpointLinkService +from app.utils.mongo_validator import PyObjectId + +router = APIRouter() + + +# insert, delete, get, get_all +@router.post("", response_model=PresentationCheckpointLink) +async def insert_presentation_checkpoint_link( + presentation: PresentationCheckpointLink, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + presentationDB = PresentationCheckpointLinkInDB(**presentation.dict()) + presentationDB.username_insert = current_user.username + inserted = PresentationCheckpointLinkService.insert(presentationDB) + return inserted + + +@router.delete("/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) +async def delete_presentation_checkpoint_link( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = PresentationCheckpointLinkService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="PresentationCheckpointLink not found", + ) + PresentationCheckpointLinkService.delete(id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/{id}", response_model=PresentationCheckpointLink) +async def get_presentation_checkpoint_link( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = PresentationCheckpointLinkService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="PresentationCheckpointLink not found", + ) + return find + + +@router.get("", response_model=List[PresentationCheckpointLink]) +async def get_all_presentation_checkpoint_links( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = PresentationCheckpointLinkService.get_all() + return finds + + +@router.put("", response_model=PresentationCheckpointLink) +async def update_presentation_checkpoint_link( + presentation: PresentationCheckpointLink, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = PresentationCheckpointLinkService.get(presentation.id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Product not found", + ) + presentationDB = PresentationCheckpointLinkInDB(**presentation.dict()) + presentationDB.username_update = current_user.username + updated = PresentationCheckpointLinkService.update(presentation.id, presentationDB) + return updated + + +@router.get( + "/{id_presentation}/generate-links", + response_model=list[PresentationCheckpointLink], +) +async def generate_presentation_checkpoint_links( + id_presentation: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = PresentationService.get(id_presentation) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Presentation not found", + ) + generated_links = await PresentationCheckpointLinkService.generate_links(find) + return generated_links + + +@router.get("/{id}/activate", response_model=PresentationCheckpointLink) +async def activate_presentation_checkpoint_link( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = PresentationCheckpointLinkService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="PresentationCheckpointLink not found", + ) + now = datetime.utcnow() + start = find.start_duration + if start is None or start < now: + start = now + activated = PresentationCheckpointLinkService.active_link(id, start) + if activated is None: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Failed to activate PresentationCheckpointLink", + ) + return activated + + +@router.get("/{id}/deactivate", response_model=PresentationCheckpointLink) +async def deactivate_presentation_checkpoint_link( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = PresentationCheckpointLinkService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="PresentationCheckpointLink not found", + ) + deactivated = PresentationCheckpointLinkService.deactivate_link(id) + if deactivated is None: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Failed to deactivate PresentationCheckpointLink", + ) + return deactivated diff --git a/app/routers/presentations.py b/app/routers/presentations.py index 18c79f8..f91d907 100644 --- a/app/routers/presentations.py +++ b/app/routers/presentations.py @@ -1,79 +1,85 @@ -from typing import Annotated, List -from fastapi import APIRouter, Depends, Response, status -from fastapi.responses import HTMLResponse -from app.auth.auth import get_current_active_user -from app.models.presentation import Presentation, PresentationInDB - -from app.models.user import UserInDB -from app.services.presentation import PresentationService -from app.utils.mongo_validator import PyObjectId - - -router = APIRouter() - - -# insert, delete, get, get_all -@router.post("", response_model=Presentation) -async def insert_presentation( - presentation: Presentation, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - presentationDB = PresentationInDB(**presentation.dict()) - presentationDB.username_insert = current_user.username - inserted = PresentationService.insert(presentationDB) - return inserted - - -@router.delete("/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) -async def delete_presentation( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = PresentationService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Presentation not found" - ) - PresentationService.delete(id) - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -@router.get("/{id}", response_model=Presentation) -async def get_presentation( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = PresentationService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Presentation not found" - ) - return find - - -@router.get("", response_model=List[Presentation]) -async def get_all_presentation( - current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = PresentationService.get_all() - return finds - -@router.get("/get/talks", response_model=List[Presentation]) -async def get_all_presentation_talks( - current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = PresentationService.get_all_talks() - return finds - - -@router.patch("/{id}/points", response_model=Presentation) -async def update_presentation( - id: PyObjectId, - points: int, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - find = PresentationService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Presentation not found" - ) - updated = PresentationService.update_presentation(id, points) - return updated +from typing import Annotated, List + +from fastapi import APIRouter, Depends, Response, status +from fastapi.responses import HTMLResponse + +from app.auth.auth import get_current_active_user +from app.models.presentation import Presentation, PresentationInDB +from app.models.user import UserInDB +from app.services.presentation import PresentationService +from app.utils.mongo_validator import PyObjectId + +router = APIRouter() + + +# insert, delete, get, get_all +@router.post("", response_model=Presentation) +async def insert_presentation( + presentation: Presentation, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + presentationDB = PresentationInDB(**presentation.dict()) + presentationDB.username_insert = current_user.username + inserted = PresentationService.insert(presentationDB) + return inserted + + +@router.delete("/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) +async def delete_presentation( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = PresentationService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Presentation not found", + ) + PresentationService.delete(id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/{id}", response_model=Presentation) +async def get_presentation( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = PresentationService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Presentation not found", + ) + return find + + +@router.get("", response_model=List[Presentation]) +async def get_all_presentation( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = PresentationService.get_all() + return finds + + +@router.get("/get/talks", response_model=List[Presentation]) +async def get_all_presentation_talks( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = PresentationService.get_all_talks() + return finds + + +@router.patch("/{id}/points", response_model=Presentation) +async def update_presentation( + id: PyObjectId, + points: int, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = PresentationService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Presentation not found", + ) + updated = PresentationService.update_presentation(id, points) + return updated diff --git a/app/routers/products.py b/app/routers/products.py index b7b34c9..96c8926 100644 --- a/app/routers/products.py +++ b/app/routers/products.py @@ -1,81 +1,106 @@ -from typing import Annotated, List -from fastapi import APIRouter, Depends, Response, status -from fastapi.responses import HTMLResponse -from app.auth.auth import get_current_active_user -from app.models.product import Product, ProductInDB - -from app.models.user import UserInDB -from app.services.product import ProductService -from app.utils.mongo_validator import PyObjectId - - -router = APIRouter() - - -# insert, delete, get, get_all -@router.post("", response_model=Product) -async def insert_product( - product: Product, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - productDB = ProductInDB(**product.dict()) - productDB.username_insert = current_user.username - inserted = ProductService.insert(productDB) - return inserted - - -@router.delete("/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) -async def delete_product( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = ProductService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Product not found" - ) - ProductService.delete(id) - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -@router.put("", response_model=Product) -async def update_product( - product: Product, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - find = ProductService.get(product.id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Product not found" - ) - productDB = ProductInDB(**product.dict()) - productDB.username_update = current_user.username - updated = ProductService.update(product.id, productDB) - return updated - - -@router.get("/{id}", response_model=Product) -async def get_product( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = ProductService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Product not found" - ) - return find - - -@router.get("", response_model=List[Product]) -async def get_all_product( - current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = ProductService.get_all() - return finds - - -@router.get("/enable-to-buy", response_model=List[Product]) -async def get_all_enable_to_buy_product( - current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = ProductService.get_all_enable_to_buy() - return finds \ No newline at end of file +from typing import Annotated, List + +from fastapi import APIRouter, Depends, Response, status +from fastapi.responses import HTMLResponse + +from app.auth.auth import get_current_active_user +from app.models.product import Product, ProductInDB +from app.models.user import UserInDB +from app.services.product import ProductService +from app.utils.mongo_validator import PyObjectId + +router = APIRouter() + + +# insert, delete, get, get_all +@router.post("", response_model=Product) +async def insert_product( + product: Product, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + productDB = ProductInDB(**product.dict()) + productDB.username_insert = current_user.username + inserted = ProductService.insert(productDB) + return inserted + + +@router.delete("/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) +async def delete_product( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = ProductService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Product not found", + ) + ProductService.delete(id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.put("", response_model=Product) +async def update_product( + product: Product, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = ProductService.get(product.id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Product not found", + ) + productDB = ProductInDB(**product.dict()) + productDB.username_update = current_user.username + updated = ProductService.update(product.id, productDB) + return updated + + +@router.get("/{id}", response_model=Product) +async def get_product( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = ProductService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Product not found", + ) + return find + + +@router.get("", response_model=List[Product]) +async def get_all_product( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = ProductService.get_all() + return finds + + +@router.get("/enable-to-buy", response_model=List[Product]) +async def get_all_enable_to_buy_product( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = ProductService.get_all_enable_to_buy() + return finds + + +@router.post("/{id}/codes", response_model=Product) +async def add_product_codes( + id: PyObjectId, + codes: List[str], + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = ProductService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Product not found", + ) + if find.kind != "discount_code": + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Product is not a discount code product", + ) + return ProductService.add_codes(id, codes) diff --git a/app/routers/self_service.py b/app/routers/self_service.py new file mode 100644 index 0000000..fb0182a --- /dev/null +++ b/app/routers/self_service.py @@ -0,0 +1,301 @@ +import logging +from datetime import datetime + +from fastapi import APIRouter, BackgroundTasks, status +from fastapi.responses import HTMLResponse + +from app.models.control import ControlInDB +from app.models.gift import SelfServiceGift +from app.models.participant import ( + ParticipantInDB, + SelfServiceCheckRequest, + SelfServiceParticipant, + SelfServiceRedeem, + SelfServiceRedeemRequest, + SelfServiceRegisterRequest, + SelfServiceResult, +) +from app.models.point import PointInDB +from app.services.control import ControlService +from app.services.gift import GiftService +from app.services.location import LocationService +from app.services.participant import ParticipantService +from app.services.point import PointService +from app.services.presentation import PresentationService +from app.services.presentation_checkpoint_link import PresentationCheckpointLinkService +from app.services.redemption import ( + RedemptionError, + redeem_gift, + redeem_virtual_meal, + send_redeem_email, +) +from app.utils.control_code_generator import generate_name +from app.utils.qr_generator import qr_to_base64 + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.post("/registration", response_model=SelfServiceParticipant) +async def self_service_registration(register: SelfServiceRegisterRequest): + ci = register.ci + birthday = register.birthday + birthday_datetime = datetime(birthday.year, birthday.month, birthday.day) + find = ParticipantService.get_by_ci_and_birthday(ci=ci, birthday=birthday_datetime) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + if find.control_code or find.registration: + control_code = find.control_code + else: + exist = False + control_code = "" + while not exist: + control_code = generate_name() + exist = not ParticipantService.exist_control_code(control_code) + finish = ParticipantService.finish_registration(find.id, control_code) + if not finish: + return HTMLResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content="Failed to finish registration", + ) + qr_image = qr_to_base64(control_code) + participant = SelfServiceParticipant( + control_code=control_code, + qr_code=qr_image, + ) + return participant + + +@router.get("/check/{code}", response_model=SelfServiceResult) +async def self_service_participation(code: str): + + find = PresentationCheckpointLinkService.get_by_code(code) + if find is None: + return HTMLResponse( + "Presentation not found", + status_code=status.HTTP_404_NOT_FOUND, + ) + if not find.active: + return HTMLResponse( + "Presentation not active", + status_code=status.HTTP_400_BAD_REQUEST, + ) + now = datetime.utcnow() + if not find.start_duration or not find.end_duration: + return HTMLResponse( + "Presentation duration undefined", + status_code=status.HTTP_400_BAD_REQUEST, + ) + if not (find.start_duration <= now <= find.end_duration): + return HTMLResponse( + "Presentation not within active duration", + status_code=status.HTTP_400_BAD_REQUEST, + ) + return SelfServiceResult( + control_code="", + message="Presentation active", + ) + + +@router.post("/check/{code}", response_model=SelfServiceResult) +async def self_service_participation_register( + code: str, + checks: SelfServiceCheckRequest, +): + control_code = checks.control_code + find = PresentationCheckpointLinkService.get_by_code(code) + if find is None: + return HTMLResponse( + "Presentation not found", + status_code=status.HTTP_404_NOT_FOUND, + ) + if not find.active: + return HTMLResponse( + "Presentation not active", + status_code=status.HTTP_400_BAD_REQUEST, + ) + now = datetime.utcnow() + if not find.start_duration or not find.end_duration: + return HTMLResponse( + "Presentation duration undefined", + status_code=status.HTTP_400_BAD_REQUEST, + ) + if not (find.start_duration <= now <= find.end_duration): + return HTMLResponse( + "Presentation not within active duration", + status_code=status.HTTP_400_BAD_REQUEST, + ) + participant = ParticipantService.get_by_control_code(control_code) + if participant is None: + return HTMLResponse( + "Participant not found", + status_code=status.HTTP_404_NOT_FOUND, + ) + if not participant: + return HTMLResponse( + "Participants with same control code found", + status_code=status.HTTP_400_BAD_REQUEST, + ) + + presentation = PresentationService.get(find.presentation) + if presentation is None: + return HTMLResponse( + "Presentation not found", + status_code=status.HTTP_404_NOT_FOUND, + ) + location = LocationService.get(presentation.location) + if location is None: + return HTMLResponse( + "Location not found", + status_code=status.HTTP_404_NOT_FOUND, + ) + + if PointService.exist_by_participant_and_checkpoint(control_code, find.id): + return HTMLResponse( + "Checkpoint already registered", + status_code=status.HTTP_400_BAD_REQUEST, + ) + + user_control = "self_service" + in_and_outs = ControlService.count_in_and_outs(control_code, presentation.location) + if in_and_outs <= 0: + # Virtual attendance: auto check-in, unless checked in elsewhere + if ControlService.count_in_and_outs_global(control_code) > 0: + return SelfServiceResult( + control_code=control_code, + message="Current check in a location", + ) + check_in = ControlInDB( + participant_control_code=control_code, + location=presentation.location, + location_name=location.name, + control="in", + date=datetime.now(), + user_control=user_control, + ) + check_in.username_insert = user_control + ControlService.insert(check_in) + + ParticipantService.add_points(participant.id, find.points) + point = PointInDB( + participant_control_code=control_code, + presentation=presentation.id, + points=find.points, + date=datetime.now(), + user_control=user_control, + checkpoint_link=find.id, + ) + point.username_insert = user_control + PointService.insert(point) + + if find.checkpoint >= presentation.attendance_checkpoints: + check_out = ControlInDB( + participant_control_code=control_code, + location=presentation.location, + location_name=location.name, + control="out", + date=datetime.now(), + user_control=user_control, + ) + check_out.username_insert = user_control + ControlService.insert(check_out) + + return SelfServiceResult( + control_code=control_code, + message="Checkpoint registered", + points=find.points, + ) + + +def _get_self_service_participant( + control_code: str, + ci: str, +) -> ParticipantInDB | HTMLResponse: + participant = ParticipantService.get_by_control_code(control_code) + # Same message for unknown code and CI mismatch to avoid leaking valid codes + if not isinstance(participant, ParticipantInDB) or participant.ci != ci: + return HTMLResponse( + content="Participant not found", + status_code=status.HTTP_400_BAD_REQUEST, + ) + return participant + + +@router.get("/gifts", response_model=list[SelfServiceGift]) +async def self_service_gifts(): + return GiftService.get_all_virtual() + + +@router.post("/redeem/gift", response_model=SelfServiceRedeem) +async def self_service_redeem_gift( + redeem_request: SelfServiceRedeemRequest, + background_tasks: BackgroundTasks, +): + control_code = redeem_request.control_code + ci = redeem_request.ci + gift_id = redeem_request.gift + participant = _get_self_service_participant(control_code, ci) + if isinstance(participant, HTMLResponse): + return participant + try: + redeem, product = redeem_gift( + participant, + gift_id, + "self_service", + virtual=True, + ) + except RedemptionError as error: + return HTMLResponse( + content=error.message, + status_code=status.HTTP_400_BAD_REQUEST, + ) + background_tasks.add_task( + send_redeem_email, + participant, + product, + redeem.discount_code, + redeem.download_url, + ) + return SelfServiceRedeem( + control_code=control_code, + message="Gift redeemed", + product_name=product.name, + discount_code=redeem.discount_code, + download_url=redeem.download_url, + ) + + +@router.post("/redeem/meal", response_model=SelfServiceRedeem) +async def self_service_redeem_meal( + redeem_request: SelfServiceRedeemRequest, + background_tasks: BackgroundTasks, +): + control_code = redeem_request.control_code + ci = redeem_request.ci + participant = _get_self_service_participant(control_code, ci) + if isinstance(participant, HTMLResponse): + return participant + try: + redeem, product = redeem_virtual_meal(participant, "self_service") + except RedemptionError as error: + return HTMLResponse( + content=error.message, + status_code=status.HTTP_400_BAD_REQUEST, + ) + background_tasks.add_task( + send_redeem_email, + participant, + product, + redeem.discount_code, + redeem.download_url, + ) + return SelfServiceRedeem( + control_code=control_code, + message="Meal redeemed", + product_name=product.name, + discount_code=redeem.discount_code, + download_url=redeem.download_url, + ) diff --git a/app/routers/special_point.py b/app/routers/special_point.py index ae945a2..378158c 100644 --- a/app/routers/special_point.py +++ b/app/routers/special_point.py @@ -1,99 +1,117 @@ -from datetime import datetime -from typing import Annotated, List -from fastapi import APIRouter, Depends, Response, status -from fastapi.responses import HTMLResponse -from app.auth.auth import get_current_active_user, get_current_username_admin -from app.core.roles import Roles -from app.models.special_point import SpecialPoint, SpecialPointInDB, SpecialPointRedeemInDB -from app.models.user import UserInDB -from app.services.participant import ParticipantService -from app.services.special_point import SpecialPointService -from app.utils.mongo_validator import PyObjectId - - -router = APIRouter() - - -# insert, delete, get, get_all -@router.post("", response_model=SpecialPoint) -async def insert_special_point( - point: SpecialPoint, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - pointDB = SpecialPointInDB(**point.dict()) - pointDB.username_insert = current_user.username - inserted = SpecialPointService.insert(pointDB) - return inserted - - -@router.delete("/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) -async def delete_special_point( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = SpecialPointService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Special Point not found" - ) - SpecialPointService.delete(id) - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -@router.get("/{id}", response_model=SpecialPoint) -async def get_special_point( - id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - find = SpecialPointService.get(id) - if find is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Point not found" - ) - return find - - -@router.get("", response_model=List[SpecialPoint]) -async def get_all_special_point( - current_user: Annotated[UserInDB, Depends(get_current_active_user)] -): - finds = SpecialPointService.get_all() - return finds - -@router.get("/redeem/special", response_model=SpecialPoint) -async def insert_special_point_redeem( - participant_control_code: str, - special_point: PyObjectId, - current_user: Annotated[UserInDB, Depends(get_current_active_user)], -): - if Roles.SPECIAL_POINT not in current_user.roles: - return HTMLResponse( - status_code=status.HTTP_401_UNAUTHORIZED, content="Unauthorized" - ) - point = SpecialPointService.get(special_point) - if point is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Special Point not found" - ) - participant = ParticipantService.get_by_control_code(participant_control_code) - if participant is None: - return HTMLResponse( - status_code=status.HTTP_404_NOT_FOUND, content="Participant not found" - ) - - if point.limit > 0: - already_redeemed = SpecialPointService.get_redeems(participant_control_code, special_point) - size_redeemed = len(already_redeemed) - if size_redeemed >= point.limit: - return HTMLResponse( - status_code=status.HTTP_400_BAD_REQUEST, content="Limit reached" - ) - - redeem = SpecialPointRedeemInDB( - special_point=special_point, - points=point.points, - participant_control_code=participant_control_code, - date=datetime.now(), - user_control=current_user.username, - ) - redeem.username_insert = current_user.username - inserted = SpecialPointService.insert_redeem(redeem) - participant = ParticipantService.add_points(participant.id, point.points) - return inserted +from datetime import datetime +from typing import Annotated, List + +from fastapi import APIRouter, Depends, Response, status +from fastapi.responses import HTMLResponse + +from app.auth.auth import get_current_active_user +from app.core.roles import Roles +from app.models.special_point import ( + SpecialPoint, + SpecialPointInDB, + SpecialPointRedeemInDB, +) +from app.models.user import UserInDB +from app.services.participant import ParticipantService +from app.services.special_point import SpecialPointService +from app.utils.mongo_validator import PyObjectId + +router = APIRouter() + + +# insert, delete, get, get_all +@router.post("", response_model=SpecialPoint) +async def insert_special_point( + point: SpecialPoint, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + pointDB = SpecialPointInDB(**point.dict()) + pointDB.username_insert = current_user.username + inserted = SpecialPointService.insert(pointDB) + return inserted + + +@router.delete("/{id}", responses={status.HTTP_204_NO_CONTENT: {"model": None}}) +async def delete_special_point( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = SpecialPointService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Special Point not found", + ) + SpecialPointService.delete(id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/{id}", response_model=SpecialPoint) +async def get_special_point( + id: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + find = SpecialPointService.get(id) + if find is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Point not found", + ) + return find + + +@router.get("", response_model=List[SpecialPoint]) +async def get_all_special_point( + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + finds = SpecialPointService.get_all() + return finds + + +@router.get("/redeem/special", response_model=SpecialPoint) +async def insert_special_point_redeem( + participant_control_code: str, + special_point: PyObjectId, + current_user: Annotated[UserInDB, Depends(get_current_active_user)], +): + if Roles.SPECIAL_POINT not in current_user.roles: + return HTMLResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content="Unauthorized", + ) + point = SpecialPointService.get(special_point) + if point is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Special Point not found", + ) + participant = ParticipantService.get_by_control_code(participant_control_code) + if participant is None: + return HTMLResponse( + status_code=status.HTTP_404_NOT_FOUND, + content="Participant not found", + ) + + if point.limit > 0: + already_redeemed = SpecialPointService.get_redeems( + participant_control_code, + special_point, + ) + size_redeemed = len(already_redeemed) + if size_redeemed >= point.limit: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Limit reached", + ) + + redeem = SpecialPointRedeemInDB( + special_point=special_point, + points=point.points, + participant_control_code=participant_control_code, + date=datetime.now(), + user_control=current_user.username, + ) + redeem.username_insert = current_user.username + inserted = SpecialPointService.insert_redeem(redeem) + participant = ParticipantService.add_points(participant.id, point.points) + return inserted diff --git a/app/services/buy.py b/app/services/buy.py index 528047f..0d4b214 100644 --- a/app/services/buy.py +++ b/app/services/buy.py @@ -1,63 +1,63 @@ -from datetime import datetime -from typing import List -from pymongo.collection import ReturnDocument - -from app.core.database import db -from app.models.buy import BuyProductInDB -from app.utils.mongo_validator import PyObjectId - - -class BuyService: - TABLE = db.buys - - @classmethod - def insert(cls, item: BuyProductInDB) -> BuyProductInDB: - if hasattr(item, "id"): - delattr(item, "id") - item.date_insert = datetime.utcnow() - item.date_update = item.date_insert - inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) - item.id = inserted.inserted_id - return item - - @classmethod - def delete(cls, id: PyObjectId) -> BuyProductInDB | None: - deleted = cls.TABLE.find_one_and_update( - {"_id": id}, - {"$set": {"disabled": True}}, - return_document=ReturnDocument.AFTER, - ) - if deleted is not None: - return BuyProductInDB(**deleted) - else: - return None - - @classmethod - def get(cls, id: PyObjectId) -> BuyProductInDB | None: - item = cls.TABLE.find_one({"_id": id, "disabled": False}) - if item is not None: - return BuyProductInDB(**item) - return item - - @classmethod - def get_all(cls) -> List[BuyProductInDB]: - items = cls.TABLE.find({"disabled": False}) - return [BuyProductInDB(**item) for item in items] - - @classmethod - def get_by_participant_control_code( - cls, participant_control_code: str - ) -> List[BuyProductInDB]: - items = cls.TABLE.find( - {"participant_control_code": participant_control_code, "disabled": False} - ) - return [BuyProductInDB(**item) for item in items] - - @classmethod - def exist_participant_control_code(cls, participant_control_code: str) -> bool: - items = cls.TABLE.count_documents( - {"participant_control_code": participant_control_code, "disabled": False} - ) - if items > 0: - return True - return False +from datetime import datetime +from typing import List + +from pymongo.collection import ReturnDocument + +from app.core.database import db +from app.models.buy import BuyProductInDB +from app.utils.mongo_validator import PyObjectId + + +class BuyService: + TABLE = db.buys + + @classmethod + def insert(cls, item: BuyProductInDB) -> BuyProductInDB: + if hasattr(item, "id"): + delattr(item, "id") + item.date_insert = datetime.utcnow() + item.date_update = item.date_insert + inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) + item.id = inserted.inserted_id + return item + + @classmethod + def delete(cls, id: PyObjectId) -> BuyProductInDB | None: + deleted = cls.TABLE.find_one_and_update( + {"_id": id}, + {"$set": {"disabled": True}}, + return_document=ReturnDocument.AFTER, + ) + if deleted is not None: + return BuyProductInDB(**deleted) + else: + return None + + @classmethod + def get(cls, id: PyObjectId) -> BuyProductInDB | None: + item = cls.TABLE.find_one({"_id": id, "disabled": False}) + if item is not None: + return BuyProductInDB(**item) + return item + + @classmethod + def get_all(cls) -> List[BuyProductInDB]: + items = cls.TABLE.find({"disabled": False}) + return [BuyProductInDB(**item) for item in items] + + @classmethod + def get_by_participant_control_code( + cls, + participant_control_code: str, + ) -> List[BuyProductInDB]: + items = cls.TABLE.find( + {"participant_control_code": participant_control_code, "disabled": False}, + ) + return [BuyProductInDB(**item) for item in items] + + @classmethod + def exist_participant_control_code(cls, participant_control_code: str) -> bool: + items = cls.TABLE.count_documents( + {"participant_control_code": participant_control_code, "disabled": False}, + ) + return items > 0 diff --git a/app/services/combo.py b/app/services/combo.py index 114f840..a4a8f58 100644 --- a/app/services/combo.py +++ b/app/services/combo.py @@ -1,198 +1,200 @@ -from datetime import datetime -from typing import List -from pymongo.collection import ReturnDocument - -from app.core.database import db -from app.models.combo import ComboInDB, RedeemComboInDB -from app.utils.mongo_validator import PyObjectId - - -class ComboService: - TABLE = db.combos - - @classmethod - def insert(cls, item: ComboInDB) -> ComboInDB: - if hasattr(item, "id"): - delattr(item, "id") - if hasattr(item, "products_items"): - delattr(item, "products_items") - item.date_insert = datetime.utcnow() - item.date_update = item.date_insert - inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) - item.id = inserted.inserted_id - return item - - @classmethod - def delete(cls, id: PyObjectId) -> ComboInDB | None: - deleted = cls.TABLE.find_one_and_update( - {"_id": id}, - {"$set": {"disabled": True}}, - return_document=ReturnDocument.AFTER, - ) - if deleted is not None: - return ComboInDB(**deleted) - else: - return None - - @classmethod - def update(cls, id: PyObjectId, item: ComboInDB) -> ComboInDB | None: - item.date_update = datetime.utcnow() - if hasattr(item, "id"): - delattr(item, "id") - if hasattr(item, "date_insert"): - delattr(item, "date_insert") - if hasattr(item, "username_insert"): - delattr(item, "username_insert") - if hasattr(item, "disabled"): - delattr(item, "disabled") - if hasattr(item, "products_items"): - delattr(item, "products_items") - updated = cls.TABLE.find_one_and_update( - {"_id": id, "disabled": False}, - {"$set": item.dict(by_alias=True)}, - return_document=ReturnDocument.AFTER, - ) - if updated is not None: - return ComboInDB(**updated) - else: - return None - - @classmethod - def get(cls, id: PyObjectId) -> ComboInDB | None: - item = cls.TABLE.find_one({"_id": id, "disabled": False}) - if item is not None: - return ComboInDB(**item) - return item - - @classmethod - def get_all(cls) -> List[ComboInDB]: - items = cls.TABLE.find({"disabled": False}) - return [ComboInDB(**item) for item in items] - - @classmethod - def get_all_and_products(cls) -> List[ComboInDB]: - query = [ - { - "$lookup": { - "from": "products", - "localField": "products", - "foreignField": "_id", - "as": "products", - } - }, - {"$match": {"disabled": False}}, - { - "$project": { - "_id": 1, - "name": 1, - "products": { - "$map": { - "input": "$products", - "as": "product", - "in": "$$product._id", - } - }, - "products_items": { - "$map": { - "input": "$products", - "as": "product", - "in": { - "_id": "$$product._id", - "name": "$$product.name", - }, - } - }, - } - }, - ] - items = cls.TABLE.aggregate(query) - return [ComboInDB(**item) for item in items] - - @classmethod - def get_all_and_products_by_ids(cls, combos: List[PyObjectId]) -> List[ComboInDB]: - query = [ - { - "$lookup": { - "from": "products", - "localField": "products", - "foreignField": "_id", - "as": "products", - } - }, - {"$match": {"disabled": False, "_id": {"$in": combos}}}, - { - "$project": { - "_id": 1, - "name": 1, - "products": { - "$map": { - "input": "$products", - "as": "product", - "in": "$$product._id", - } - }, - "products_items": { - "$map": { - "input": "$products", - "as": "product", - "in": { - "_id": "$$product._id", - "name": "$$product.name", - }, - } - }, - } - }, - ] - items = cls.TABLE.aggregate(query) - return [ComboInDB(**item) for item in items] - - -class RedeemComboService: - TABLE = db.redeem_combos - - @classmethod - def insert(cls, item: RedeemComboInDB) -> RedeemComboInDB: - if hasattr(item, "id"): - delattr(item, "id") - item.date_insert = datetime.utcnow() - item.date_update = item.date_insert - inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) - item.id = inserted.inserted_id - return item - - @classmethod - def get_all(cls) -> List[RedeemComboInDB]: - items = cls.TABLE.find({"disabled": False}) - return [RedeemComboInDB(**item) for item in items] - - @classmethod - def search_by_participant_and_combo( - cls, participant_control_code: str, combo_id: PyObjectId - ) -> List[RedeemComboInDB]: - items = cls.TABLE.find( - { - "participant_control_code": participant_control_code, - "combo": combo_id, - "disabled": False, - } - ) - return [RedeemComboInDB(**item) for item in items] - - @classmethod - def search_by_participant( - cls, participant_control_code: str - ) -> List[RedeemComboInDB]: - items = cls.TABLE.find( - {"participant_control_code": participant_control_code, "disabled": False} - ) - return [RedeemComboInDB(**item) for item in items] - - @classmethod - def exist_participant_control_code(cls, participant_control_code: str) -> bool: - items = cls.TABLE.count_documents( - {"participant_control_code": participant_control_code, "disabled": False} - ) - if items > 0: - return True - return False +from datetime import datetime +from typing import List + +from pymongo.collection import ReturnDocument + +from app.core.database import db +from app.models.combo import ComboInDB, RedeemComboInDB +from app.utils.mongo_validator import PyObjectId + + +class ComboService: + TABLE = db.combos + + @classmethod + def insert(cls, item: ComboInDB) -> ComboInDB: + if hasattr(item, "id"): + delattr(item, "id") + if hasattr(item, "products_items"): + delattr(item, "products_items") + item.date_insert = datetime.utcnow() + item.date_update = item.date_insert + inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) + item.id = inserted.inserted_id + return item + + @classmethod + def delete(cls, id: PyObjectId) -> ComboInDB | None: + deleted = cls.TABLE.find_one_and_update( + {"_id": id}, + {"$set": {"disabled": True}}, + return_document=ReturnDocument.AFTER, + ) + if deleted is not None: + return ComboInDB(**deleted) + else: + return None + + @classmethod + def update(cls, id: PyObjectId, item: ComboInDB) -> ComboInDB | None: + item.date_update = datetime.utcnow() + if hasattr(item, "id"): + delattr(item, "id") + if hasattr(item, "date_insert"): + delattr(item, "date_insert") + if hasattr(item, "username_insert"): + delattr(item, "username_insert") + if hasattr(item, "disabled"): + delattr(item, "disabled") + if hasattr(item, "products_items"): + delattr(item, "products_items") + updated = cls.TABLE.find_one_and_update( + {"_id": id, "disabled": False}, + {"$set": item.dict(by_alias=True)}, + return_document=ReturnDocument.AFTER, + ) + if updated is not None: + return ComboInDB(**updated) + else: + return None + + @classmethod + def get(cls, id: PyObjectId) -> ComboInDB | None: + item = cls.TABLE.find_one({"_id": id, "disabled": False}) + if item is not None: + return ComboInDB(**item) + return item + + @classmethod + def get_all(cls) -> List[ComboInDB]: + items = cls.TABLE.find({"disabled": False}) + return [ComboInDB(**item) for item in items] + + @classmethod + def get_all_and_products(cls) -> List[ComboInDB]: + query = [ + { + "$lookup": { + "from": "products", + "localField": "products", + "foreignField": "_id", + "as": "products", + }, + }, + {"$match": {"disabled": False}}, + { + "$project": { + "_id": 1, + "name": 1, + "products": { + "$map": { + "input": "$products", + "as": "product", + "in": "$$product._id", + }, + }, + "products_items": { + "$map": { + "input": "$products", + "as": "product", + "in": { + "_id": "$$product._id", + "name": "$$product.name", + }, + }, + }, + }, + }, + ] + items = cls.TABLE.aggregate(query) + return [ComboInDB(**item) for item in items] + + @classmethod + def get_all_and_products_by_ids(cls, combos: List[PyObjectId]) -> List[ComboInDB]: + query = [ + { + "$lookup": { + "from": "products", + "localField": "products", + "foreignField": "_id", + "as": "products", + }, + }, + {"$match": {"disabled": False, "_id": {"$in": combos}}}, + { + "$project": { + "_id": 1, + "name": 1, + "products": { + "$map": { + "input": "$products", + "as": "product", + "in": "$$product._id", + }, + }, + "products_items": { + "$map": { + "input": "$products", + "as": "product", + "in": { + "_id": "$$product._id", + "name": "$$product.name", + }, + }, + }, + }, + }, + ] + items = cls.TABLE.aggregate(query) + return [ComboInDB(**item) for item in items] + + +class RedeemComboService: + TABLE = db.redeem_combos + + @classmethod + def insert(cls, item: RedeemComboInDB) -> RedeemComboInDB: + if hasattr(item, "id"): + delattr(item, "id") + item.date_insert = datetime.utcnow() + item.date_update = item.date_insert + inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) + item.id = inserted.inserted_id + return item + + @classmethod + def get_all(cls) -> List[RedeemComboInDB]: + items = cls.TABLE.find({"disabled": False}) + return [RedeemComboInDB(**item) for item in items] + + @classmethod + def search_by_participant_and_combo( + cls, + participant_control_code: str, + combo_id: PyObjectId, + ) -> List[RedeemComboInDB]: + items = cls.TABLE.find( + { + "participant_control_code": participant_control_code, + "combo": combo_id, + "disabled": False, + }, + ) + return [RedeemComboInDB(**item) for item in items] + + @classmethod + def search_by_participant( + cls, + participant_control_code: str, + ) -> List[RedeemComboInDB]: + items = cls.TABLE.find( + {"participant_control_code": participant_control_code, "disabled": False}, + ) + return [RedeemComboInDB(**item) for item in items] + + @classmethod + def exist_participant_control_code(cls, participant_control_code: str) -> bool: + items = cls.TABLE.count_documents( + {"participant_control_code": participant_control_code, "disabled": False}, + ) + return items > 0 diff --git a/app/services/control.py b/app/services/control.py index b230602..456cc54 100644 --- a/app/services/control.py +++ b/app/services/control.py @@ -1,116 +1,115 @@ -from datetime import datetime -from pymongo.collection import ReturnDocument - -from app.core.database import db -from app.models.control import ControlInDB -from app.utils.mongo_validator import PyObjectId - - -class ControlService: - TABLE = db.control - - @classmethod - def insert(cls, item: ControlInDB) -> ControlInDB: - if hasattr(item, "id"): - delattr(item, "id") - item.date_insert = datetime.utcnow() - item.date_update = item.date_insert - inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) - item.id = inserted.inserted_id - return item - - @classmethod - def get_all(cls) -> list[ControlInDB]: - items = cls.TABLE.find({"disabled": False}) - return [ControlInDB(**item) for item in items] - - @classmethod - def get_all_by_participant(cls, participant_control_code: str) -> list[ControlInDB]: - items = cls.TABLE.find( - {"participant_control_code": participant_control_code, "disabled": False} - ) - return [ControlInDB(**item) for item in items] - - @classmethod - def count_checks( - cls, participant_control_code: str, location: PyObjectId, control: str - ) -> int: - return cls.TABLE.count_documents( - { - "participant_control_code": participant_control_code, - "location": location, - "control": control, - "disabled": False, - } - ) - - @classmethod - def count_in_and_outs_global( - cls, participant_control_code: str - ) -> int: - # 1: current in - # 0: empty - # -1: current out - query = [ - { - "$match": { - "disabled": False, - "participant_control_code": participant_control_code, - } - }, - { - "$group": { - "_id": None, - "count_in_outs": { - "$sum": {"$cond": [{"$eq": ["$control", "in"]}, 1, -1]} - }, - } - }, - ] - items = cls.TABLE.aggregate(query) - result = [dict(**item) for item in items] - if result != []: - return result[0]["count_in_outs"] - else: - return 0 - - @classmethod - def count_in_and_outs( - cls, participant_control_code: str, location: PyObjectId - ) -> int: - # 1: current in - # 0: empty - # -1: current out - query = [ - { - "$match": { - "disabled": False, - "participant_control_code": participant_control_code, - "location": location, - } - }, - { - "$group": { - "_id": None, - "count_in_outs": { - "$sum": {"$cond": [{"$eq": ["$control", "in"]}, 1, -1]} - }, - } - }, - ] - items = cls.TABLE.aggregate(query) - result = [dict(**item) for item in items] - if result != []: - return result[0]["count_in_outs"] - else: - return 0 - - - @classmethod - def exist_participant_control_code(cls, participant_control_code: str) -> bool: - items = cls.TABLE.count_documents( - {"participant_control_code": participant_control_code, "disabled": False} - ) - if items > 0: - return True - return False +from datetime import datetime + +from app.core.database import db +from app.models.control import ControlInDB +from app.utils.mongo_validator import PyObjectId + + +class ControlService: + TABLE = db.control + + @classmethod + def insert(cls, item: ControlInDB) -> ControlInDB: + if hasattr(item, "id"): + delattr(item, "id") + item.date_insert = datetime.utcnow() + item.date_update = item.date_insert + inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) + item.id = inserted.inserted_id + return item + + @classmethod + def get_all(cls) -> list[ControlInDB]: + items = cls.TABLE.find({"disabled": False}) + return [ControlInDB(**item) for item in items] + + @classmethod + def get_all_by_participant(cls, participant_control_code: str) -> list[ControlInDB]: + items = cls.TABLE.find( + {"participant_control_code": participant_control_code, "disabled": False}, + ) + return [ControlInDB(**item) for item in items] + + @classmethod + def count_checks( + cls, + participant_control_code: str, + location: PyObjectId, + control: str, + ) -> int: + return cls.TABLE.count_documents( + { + "participant_control_code": participant_control_code, + "location": location, + "control": control, + "disabled": False, + }, + ) + + @classmethod + def count_in_and_outs_global(cls, participant_control_code: str) -> int: + # 1: current in + # 0: empty + # -1: current out + query = [ + { + "$match": { + "disabled": False, + "participant_control_code": participant_control_code, + }, + }, + { + "$group": { + "_id": None, + "count_in_outs": { + "$sum": {"$cond": [{"$eq": ["$control", "in"]}, 1, -1]}, + }, + }, + }, + ] + items = cls.TABLE.aggregate(query) + result = [dict(**item) for item in items] + if result != []: + return result[0]["count_in_outs"] + else: + return 0 + + @classmethod + def count_in_and_outs( + cls, + participant_control_code: str, + location: PyObjectId, + ) -> int: + # 1: current in + # 0: empty + # -1: current out + query = [ + { + "$match": { + "disabled": False, + "participant_control_code": participant_control_code, + "location": location, + }, + }, + { + "$group": { + "_id": None, + "count_in_outs": { + "$sum": {"$cond": [{"$eq": ["$control", "in"]}, 1, -1]}, + }, + }, + }, + ] + items = cls.TABLE.aggregate(query) + result = [dict(**item) for item in items] + if result != []: + return result[0]["count_in_outs"] + else: + return 0 + + @classmethod + def exist_participant_control_code(cls, participant_control_code: str) -> bool: + items = cls.TABLE.count_documents( + {"participant_control_code": participant_control_code, "disabled": False}, + ) + return items > 0 diff --git a/app/services/gift.py b/app/services/gift.py index 4d9e7d2..f853211 100644 --- a/app/services/gift.py +++ b/app/services/gift.py @@ -1,132 +1,172 @@ -from datetime import datetime -from typing import List -from pymongo.collection import ReturnDocument - -from app.core.database import db -from app.models.gift import GiftInDB, RedeemGiftInDB -from app.utils.mongo_validator import PyObjectId - - -class GiftService: - TABLE = db.gifts - - @classmethod - def insert(cls, item: GiftInDB) -> GiftInDB: - if hasattr(item, "id"): - delattr(item, "id") - if hasattr(item, "product_name"): # Deprecated - delattr(item, "product_name") - item.date_insert = datetime.utcnow() - item.date_update = item.date_insert - inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) - item.id = inserted.inserted_id - return item - - @classmethod - def delete(cls, id: PyObjectId) -> GiftInDB | None: - deleted = cls.TABLE.find_one_and_update( - {"_id": id}, - {"$set": {"disabled": True}}, - return_document=ReturnDocument.AFTER, - ) - if deleted is not None: - return GiftInDB(**deleted) - else: - return None - - @classmethod - def get(cls, id: PyObjectId) -> GiftInDB | None: - item = cls.TABLE.find_one({"_id": id, "disabled": False}) - if item is not None: - return GiftInDB(**item) - return item - - @classmethod - def get_all(cls) -> List[GiftInDB]: - items = cls.TABLE.find({"disabled": False}) - return [GiftInDB(**item) for item in items] - - @classmethod - def get_all_gift_and_products(cls) -> List[GiftInDB]: - query = [ - { - "$lookup": { - "from": "products", - "localField": "product", - "foreignField": "_id", - "as": "product", - } - }, - {"$unwind": "$product"}, - {"$match": {"disabled": False}}, - { - "$project": { - "_id": 1, - "name": 1, - "points": 1, - "product": "$product._id", - "product_name": "$product.name", - } - }, - ] - items = cls.TABLE.aggregate(query) - return [GiftInDB(**item) for item in items] - - -class RedeemGiftService: - TABLE = db.redeem_gifts - - @classmethod - def insert(cls, item: RedeemGiftInDB) -> RedeemGiftInDB: - if hasattr(item, "id"): - delattr(item, "id") - item.date_insert = datetime.utcnow() - item.date_update = item.date_insert - inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) - item.id = inserted.inserted_id - return item - - @classmethod - def get(cls, id: PyObjectId) -> RedeemGiftInDB | None: - item = cls.TABLE.find_one({"_id": id, "disabled": False}) - if item is not None: - return RedeemGiftInDB(**item) - return item - - @classmethod - def get_all(cls) -> List[RedeemGiftInDB]: - items = cls.TABLE.find({"disabled": False}) - return [RedeemGiftInDB(**item) for item in items] - - @classmethod - def get_all_by_participant( - cls, participant_control_code: str - ) -> List[RedeemGiftInDB]: - items = cls.TABLE.find( - {"participant_control_code": participant_control_code, "disabled": False} - ) - return [RedeemGiftInDB(**item) for item in items] - - @classmethod - def get_redeem_gift_by_participant( - cls, participant_control_code: str, gift: PyObjectId - ) -> List[RedeemGiftInDB] : - items = cls.TABLE.find( - { - "participant_control_code": participant_control_code, - "gift": gift, - "disabled": False, - } - ) - if items is not None: - return [RedeemGiftInDB(**item) for item in items] - return [] - - @classmethod - def exist_participant_control_code(cls, participant_control_code: str) -> bool: - items = cls.TABLE.count_documents( - {"participant_control_code": participant_control_code, "disabled": False} - ) - if items > 0: - return True - return False +from datetime import datetime +from typing import List + +from pymongo.collection import ReturnDocument + +from app.core.database import db +from app.models.gift import GiftInDB, RedeemGiftInDB, SelfServiceGift +from app.utils.mongo_validator import PyObjectId + + +class GiftService: + TABLE = db.gifts + + @classmethod + def insert(cls, item: GiftInDB) -> GiftInDB: + if hasattr(item, "id"): + delattr(item, "id") + if hasattr(item, "product_name"): # Deprecated + delattr(item, "product_name") + item.date_insert = datetime.utcnow() + item.date_update = item.date_insert + inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) + item.id = inserted.inserted_id + return item + + @classmethod + def delete(cls, id: PyObjectId) -> GiftInDB | None: + deleted = cls.TABLE.find_one_and_update( + {"_id": id}, + {"$set": {"disabled": True}}, + return_document=ReturnDocument.AFTER, + ) + if deleted is not None: + return GiftInDB(**deleted) + else: + return None + + @classmethod + def get(cls, id: PyObjectId) -> GiftInDB | None: + item = cls.TABLE.find_one({"_id": id, "disabled": False}) + if item is not None: + return GiftInDB(**item) + return item + + @classmethod + def get_all(cls) -> List[GiftInDB]: + items = cls.TABLE.find({"disabled": False}) + return [GiftInDB(**item) for item in items] + + @classmethod + def get_all_gift_and_products(cls) -> List[GiftInDB]: + query = [ + { + "$lookup": { + "from": "products", + "localField": "product", + "foreignField": "_id", + "as": "product", + }, + }, + {"$unwind": "$product"}, + {"$match": {"disabled": False}}, + { + "$project": { + "_id": 1, + "name": 1, + "points": 1, + "product": "$product._id", + "product_name": "$product.name", + }, + }, + ] + items = cls.TABLE.aggregate(query) + return [GiftInDB(**item) for item in items] + + @classmethod + def get_all_virtual(cls) -> List[SelfServiceGift]: + query = [ + {"$match": {"disabled": False}}, + { + "$lookup": { + "from": "products", + "localField": "product", + "foreignField": "_id", + "as": "product", + }, + }, + {"$unwind": "$product"}, + { + "$match": { + "product.disabled": False, + "product.kind": {"$in": ["digital_file", "discount_code"]}, + "$or": [ + {"product.stock": None}, + {"product.stock": {"$gt": 0}}, + ], + }, + }, + { + "$project": { + "_id": 1, + "points": 1, + "multiple": 1, + "name": "$product.name", + "description": "$product.description", + "kind": "$product.kind", + "stock": "$product.stock", + }, + }, + ] + items = cls.TABLE.aggregate(query) + return [SelfServiceGift(**item) for item in items] + + +class RedeemGiftService: + TABLE = db.redeem_gifts + + @classmethod + def insert(cls, item: RedeemGiftInDB) -> RedeemGiftInDB: + if hasattr(item, "id"): + delattr(item, "id") + item.date_insert = datetime.utcnow() + item.date_update = item.date_insert + inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) + item.id = inserted.inserted_id + return item + + @classmethod + def get(cls, id: PyObjectId) -> RedeemGiftInDB | None: + item = cls.TABLE.find_one({"_id": id, "disabled": False}) + if item is not None: + return RedeemGiftInDB(**item) + return item + + @classmethod + def get_all(cls) -> List[RedeemGiftInDB]: + items = cls.TABLE.find({"disabled": False}) + return [RedeemGiftInDB(**item) for item in items] + + @classmethod + def get_all_by_participant( + cls, + participant_control_code: str, + ) -> List[RedeemGiftInDB]: + items = cls.TABLE.find( + {"participant_control_code": participant_control_code, "disabled": False}, + ) + return [RedeemGiftInDB(**item) for item in items] + + @classmethod + def get_redeem_gift_by_participant( + cls, + participant_control_code: str, + gift: PyObjectId, + ) -> List[RedeemGiftInDB]: + items = cls.TABLE.find( + { + "participant_control_code": participant_control_code, + "gift": gift, + "disabled": False, + }, + ) + if items is not None: + return [RedeemGiftInDB(**item) for item in items] + return [] + + @classmethod + def exist_participant_control_code(cls, participant_control_code: str) -> bool: + items = cls.TABLE.count_documents( + {"participant_control_code": participant_control_code, "disabled": False}, + ) + return items > 0 diff --git a/app/services/location.py b/app/services/location.py index 4ebfbf6..c7d9051 100644 --- a/app/services/location.py +++ b/app/services/location.py @@ -1,44 +1,45 @@ -from datetime import datetime -from pymongo.collection import ReturnDocument - -from app.core.database import db -from app.models.location import LocationInDB -from app.utils.mongo_validator import PyObjectId - - -class LocationService: - TABLE = db.locations - - @classmethod - def insert(cls, item: LocationInDB) -> LocationInDB: - if hasattr(item, "id"): - delattr(item, "id") - item.date_insert = datetime.utcnow() - item.date_update = item.date_insert - inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) - item.id = inserted.inserted_id - return item - - @classmethod - def delete(cls, id: PyObjectId) -> LocationInDB | None: - deleted = cls.TABLE.find_one_and_update( - {"_id": id}, - {"$set": {"disabled": True}}, - return_document=ReturnDocument.AFTER, - ) - if deleted is not None: - return LocationInDB(**deleted) - else: - return None - - @classmethod - def get(cls, id: PyObjectId) -> LocationInDB | None: - item = cls.TABLE.find_one({"_id": id, "disabled": False}) - if item is not None: - return LocationInDB(**item) - return item - - @classmethod - def get_all(cls) -> list[LocationInDB]: - items = cls.TABLE.find({"disabled": False}) - return [LocationInDB(**item) for item in items] +from datetime import datetime + +from pymongo.collection import ReturnDocument + +from app.core.database import db +from app.models.location import LocationInDB +from app.utils.mongo_validator import PyObjectId + + +class LocationService: + TABLE = db.locations + + @classmethod + def insert(cls, item: LocationInDB) -> LocationInDB: + if hasattr(item, "id"): + delattr(item, "id") + item.date_insert = datetime.utcnow() + item.date_update = item.date_insert + inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) + item.id = inserted.inserted_id + return item + + @classmethod + def delete(cls, id: PyObjectId) -> LocationInDB | None: + deleted = cls.TABLE.find_one_and_update( + {"_id": id}, + {"$set": {"disabled": True}}, + return_document=ReturnDocument.AFTER, + ) + if deleted is not None: + return LocationInDB(**deleted) + else: + return None + + @classmethod + def get(cls, id: PyObjectId) -> LocationInDB | None: + item = cls.TABLE.find_one({"_id": id, "disabled": False}) + if item is not None: + return LocationInDB(**item) + return item + + @classmethod + def get_all(cls) -> list[LocationInDB]: + items = cls.TABLE.find({"disabled": False}) + return [LocationInDB(**item) for item in items] diff --git a/app/services/meal.py b/app/services/meal.py index 8759336..71cc6c6 100644 --- a/app/services/meal.py +++ b/app/services/meal.py @@ -1,61 +1,60 @@ -from datetime import datetime -from typing import List -from pymongo.collection import ReturnDocument - -from app.core.database import db -from app.models.meal import RedeemMealInDB -from app.utils.mongo_validator import PyObjectId - - -class RedeemMealService: - TABLE = db.redeem_meals - - @classmethod - def insert(cls, item: RedeemMealInDB) -> RedeemMealInDB: - if hasattr(item, "id"): - delattr(item, "id") - item.date_insert = datetime.utcnow() - item.date_update = item.date_insert - inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) - item.id = inserted.inserted_id - return item - - @classmethod - def delete(cls, id: PyObjectId) -> RedeemMealInDB | None: - deleted = cls.TABLE.find_one_and_update( - {"_id": id}, - {"$set": {"disabled": True}}, - return_document=ReturnDocument.AFTER, - ) - if deleted is not None: - return RedeemMealInDB(**deleted) - else: - return None - - @classmethod - def get(cls, id: PyObjectId) -> RedeemMealInDB | None: - item = cls.TABLE.find_one({"_id": id, "disabled": False}) - if item is not None: - return RedeemMealInDB(**item) - return item - - @classmethod - def get_all(cls) -> List[RedeemMealInDB]: - items = cls.TABLE.find({"disabled": False}) - return [RedeemMealInDB(**item) for item in items] - - @classmethod - def get_by_participant(cls, participant_control_code: str) -> List[RedeemMealInDB]: - items = cls.TABLE.find( - {"participant_control_code": participant_control_code, "disabled": False} - ) - return [RedeemMealInDB(**item) for item in items] - - @classmethod - def exist_participant_control_code(cls, participant_control_code: str) -> bool: - items = cls.TABLE.count_documents( - {"participant_control_code": participant_control_code, "disabled": False} - ) - if items > 0: - return True - return False +from datetime import datetime +from typing import List + +from pymongo.collection import ReturnDocument + +from app.core.database import db +from app.models.meal import RedeemMealInDB +from app.utils.mongo_validator import PyObjectId + + +class RedeemMealService: + TABLE = db.redeem_meals + + @classmethod + def insert(cls, item: RedeemMealInDB) -> RedeemMealInDB: + if hasattr(item, "id"): + delattr(item, "id") + item.date_insert = datetime.utcnow() + item.date_update = item.date_insert + inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) + item.id = inserted.inserted_id + return item + + @classmethod + def delete(cls, id: PyObjectId) -> RedeemMealInDB | None: + deleted = cls.TABLE.find_one_and_update( + {"_id": id}, + {"$set": {"disabled": True}}, + return_document=ReturnDocument.AFTER, + ) + if deleted is not None: + return RedeemMealInDB(**deleted) + else: + return None + + @classmethod + def get(cls, id: PyObjectId) -> RedeemMealInDB | None: + item = cls.TABLE.find_one({"_id": id, "disabled": False}) + if item is not None: + return RedeemMealInDB(**item) + return item + + @classmethod + def get_all(cls) -> List[RedeemMealInDB]: + items = cls.TABLE.find({"disabled": False}) + return [RedeemMealInDB(**item) for item in items] + + @classmethod + def get_by_participant(cls, participant_control_code: str) -> List[RedeemMealInDB]: + items = cls.TABLE.find( + {"participant_control_code": participant_control_code, "disabled": False}, + ) + return [RedeemMealInDB(**item) for item in items] + + @classmethod + def exist_participant_control_code(cls, participant_control_code: str) -> bool: + items = cls.TABLE.count_documents( + {"participant_control_code": participant_control_code, "disabled": False}, + ) + return items > 0 diff --git a/app/services/participant.py b/app/services/participant.py index 9bb9468..14ffbf7 100644 --- a/app/services/participant.py +++ b/app/services/participant.py @@ -1,261 +1,285 @@ -from datetime import datetime -from pymongo.collection import ReturnDocument - -from app.core.database import db -from app.models.participant import ParticipantInDB -from app.utils.mongo_validator import PyObjectId - - -class ParticipantService: - TABLE = db.participants - REVOKE_TABLE = db.revoke - @classmethod - def insert(cls, item: ParticipantInDB) -> ParticipantInDB: - if hasattr(item, "id"): - delattr(item, "id") - if hasattr(item, "combos_items"): - delattr(item, "combos_items") - if hasattr(item, "locations_items"): - delattr(item, "locations_items") - item.date_insert = datetime.utcnow() - item.date_update = item.date_insert - inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) - item.id = inserted.inserted_id - return item - - @classmethod - def delete(cls, id: PyObjectId) -> ParticipantInDB | None: - deleted = cls.TABLE.find_one_and_update( - {"_id": id}, - {"$set": {"disabled": True}}, - return_document=ReturnDocument.AFTER, - ) - if deleted is not None: - return ParticipantInDB(**deleted) - else: - return None - - @classmethod - def update(cls, id: PyObjectId, item: ParticipantInDB) -> ParticipantInDB | None: - if hasattr(item, "id"): - delattr(item, "id") - item.date_update = datetime.utcnow() - - updated = cls.TABLE.find_one_and_update( - {"_id": id, "disabled": False}, - {"$set": item.dict(by_alias=True)}, - return_document=ReturnDocument.AFTER, - ) - if updated is not None: - return ParticipantInDB(**updated) - else: - return None - - @classmethod - def get(cls, id: PyObjectId) -> ParticipantInDB | None: - item = cls.TABLE.find_one({"_id": id, "disabled": False}) - if item is not None: - return ParticipantInDB(**item) - return item - - @classmethod - def get_all(cls) -> list[ParticipantInDB]: - items = cls.TABLE.find({"disabled": False}) - return [ParticipantInDB(**item) for item in items] - - @classmethod - def get_all_ids(cls) -> list[str]: - """Get array of ids""" - items = cls.TABLE.find({"disabled": False}) - return [str(item["_id"]) for item in items] - - @classmethod - def get_by_control_code(cls, control_code: str) -> ParticipantInDB | None | bool: - finds = cls.TABLE.find({"control_code": control_code, "disabled": False}) - items = [] - for find in finds: - items.append(ParticipantInDB(**find)) - - if len(items) == 1: - return items[0] - elif len(items) > 1: - return False - else: - return None - - @classmethod - def get_by_control_code_full( - cls, control_code: str - ) -> ParticipantInDB | None | bool: - query = [ - { - "$lookup": { - "from": "locations", - "localField": "locations", - "foreignField": "_id", - "as": "locations_info", - } - }, - { - "$lookup": { - "from": "combos", - "localField": "combos", - "foreignField": "_id", - "as": "combos_info", - } - }, - {"$match": {"disabled": False, "control_code": control_code}}, - { - "$project": { - "_id": 1, - "name": 1, - "gender": 1, - "t_shirt_size": 1, - "food_restriction": 1, - "registration": 1, - "phone": 1, - "role": 1, - "combos": 1, - "points": 1, - "locations": 1, - "locations_items": { - "$map": { - "input": "$locations_info", - "as": "locations", - "in": "$$locations.name", - } - }, - "combos_items": { - "$map": { - "input": "$combos_info", - "as": "combos", - "in": "$$combos.name", - } - }, - } - }, - ] - items = cls.TABLE.aggregate(query) - result = [ParticipantInDB(**item) for item in items] - if len(result) == 1: - return result[0] - elif len(result) > 1: - return False - else: - return None - - @classmethod - def get_by_control_ci(cls, ci: str) -> ParticipantInDB | None | bool: - finds = cls.TABLE.find({"ci": ci, "disabled": False}) - items = [] - for find in finds: - items.append(ParticipantInDB(**find)) - - if len(items) == 1: - return items[0] - elif len(items) > 1: - return False - else: - return None - - @classmethod - def add_points(cls, id: PyObjectId, points: int) -> ParticipantInDB | None: - updated = cls.TABLE.find_one_and_update( - {"_id": id, "disabled": False}, - {"$inc": {"points": points}, "$set": {"date_update": datetime.utcnow()}}, - return_document=ReturnDocument.AFTER, - ) - if updated is not None: - return ParticipantInDB(**updated) - else: - return None - - @classmethod - def subtract_points(cls, id: PyObjectId, points: int) -> ParticipantInDB | None: - updated = cls.TABLE.find_one_and_update( - {"_id": id, "disabled": False}, - {"$inc": {"points": -points}, "$set": {"date_update": datetime.utcnow()}}, - return_document=ReturnDocument.AFTER, - ) - if updated is not None: - return ParticipantInDB(**updated) - else: - return None - - @classmethod - def get_by_ci(cls, ci: str) -> ParticipantInDB | None: - item = cls.TABLE.find_one({"ci": ci, "disabled": False}) - if item is not None: - return ParticipantInDB(**item) - return item - - @classmethod - def exist_control_code(cls, control_code: str) -> bool: - item = cls.TABLE.find_one({"control_code": control_code, "disabled": False}) - if item is not None: - return True - return False - - @classmethod - def finish_registration( - cls, id: PyObjectId, control_code: str - ) -> ParticipantInDB | None: - updated = cls.TABLE.find_one_and_update( - {"_id": id, "disabled": False}, - { - "$set": { - "control_code": control_code, - "registration": True, - "date_update": datetime.utcnow(), - } - }, - return_document=ReturnDocument.AFTER, - ) - if updated is not None: - return ParticipantInDB(**updated) - else: - return None - - @classmethod - def insert_many(cls, items: list[ParticipantInDB], delete_id=True) -> list[PyObjectId]: - for item in items: - if hasattr(item, "id") and delete_id: - delattr(item, "id") - if hasattr(item, "locations_items"): - delattr(item, "locations_items") - if hasattr(item, "combos_items"): - delattr(item, "combos_items") - item.date_insert = datetime.utcnow() - item.date_update = item.date_insert - inserted = cls.TABLE.insert_many([item.dict(by_alias=True) for item in items]) - return inserted.inserted_ids - - @classmethod - def finish_unregistration(cls, id: PyObjectId, user:str) -> ParticipantInDB | None: - updated = cls.TABLE.find_one_and_update( - {"_id": id, "disabled": False}, - { - "$set": { - "control_code": "", - "registration": False, - "date_update": datetime.utcnow(), - "username_update": user - } - }, - return_document=ReturnDocument.AFTER, - ) - if updated is not None: - return ParticipantInDB(**updated) - else: - return None - - @classmethod - def save_revoke(cls, id: PyObjectId, control_code: str, ci: str, user:str) -> bool: - cls.REVOKE_TABLE.insert_one({ - "participant_id": id, - "control_code": control_code, - "ci": ci, - "date_insert": datetime.utcnow(), - "username_insert": user - }) - return True \ No newline at end of file +from datetime import date, datetime + +from pymongo.collection import ReturnDocument + +from app.core.database import db +from app.models.participant import ParticipantInDB +from app.utils.mongo_validator import PyObjectId + + +class ParticipantService: + TABLE = db.participants + REVOKE_TABLE = db.revoke + + @classmethod + def insert(cls, item: ParticipantInDB) -> ParticipantInDB: + if hasattr(item, "id"): + delattr(item, "id") + if hasattr(item, "combos_items"): + delattr(item, "combos_items") + if hasattr(item, "locations_items"): + delattr(item, "locations_items") + item.date_insert = datetime.utcnow() + item.date_update = item.date_insert + inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) + item.id = inserted.inserted_id + return item + + @classmethod + def delete(cls, id: PyObjectId) -> ParticipantInDB | None: + deleted = cls.TABLE.find_one_and_update( + {"_id": id}, + {"$set": {"disabled": True}}, + return_document=ReturnDocument.AFTER, + ) + if deleted is not None: + return ParticipantInDB(**deleted) + else: + return None + + @classmethod + def update(cls, id: PyObjectId, item: ParticipantInDB) -> ParticipantInDB | None: + if hasattr(item, "id"): + delattr(item, "id") + item.date_update = datetime.utcnow() + + updated = cls.TABLE.find_one_and_update( + {"_id": id, "disabled": False}, + {"$set": item.dict(by_alias=True)}, + return_document=ReturnDocument.AFTER, + ) + if updated is not None: + return ParticipantInDB(**updated) + else: + return None + + @classmethod + def get(cls, id: PyObjectId) -> ParticipantInDB | None: + item = cls.TABLE.find_one({"_id": id, "disabled": False}) + if item is not None: + return ParticipantInDB(**item) + return item + + @classmethod + def get_all(cls) -> list[ParticipantInDB]: + items = cls.TABLE.find({"disabled": False}) + return [ParticipantInDB(**item) for item in items] + + @classmethod + def get_all_ids(cls) -> list[str]: + """Get array of ids""" + items = cls.TABLE.find({"disabled": False}) + return [str(item["_id"]) for item in items] + + @classmethod + def get_by_control_code(cls, control_code: str) -> ParticipantInDB | None | bool: + finds = cls.TABLE.find({"control_code": control_code, "disabled": False}) + items = [] + for find in finds: + items.append(ParticipantInDB(**find)) + + if len(items) == 1: + return items[0] + elif len(items) > 1: + return False + else: + return None + + @classmethod + def get_by_control_code_full( + cls, + control_code: str, + ) -> ParticipantInDB | None | bool: + query = [ + { + "$lookup": { + "from": "locations", + "localField": "locations", + "foreignField": "_id", + "as": "locations_info", + }, + }, + { + "$lookup": { + "from": "combos", + "localField": "combos", + "foreignField": "_id", + "as": "combos_info", + }, + }, + {"$match": {"disabled": False, "control_code": control_code}}, + { + "$project": { + "_id": 1, + "name": 1, + "gender": 1, + "t_shirt_size": 1, + "food_restriction": 1, + "registration": 1, + "phone": 1, + "role": 1, + "combos": 1, + "points": 1, + "locations": 1, + "locations_items": { + "$map": { + "input": "$locations_info", + "as": "locations", + "in": "$$locations.name", + }, + }, + "combos_items": { + "$map": { + "input": "$combos_info", + "as": "combos", + "in": "$$combos.name", + }, + }, + }, + }, + ] + items = cls.TABLE.aggregate(query) + result = [ParticipantInDB(**item) for item in items] + if len(result) == 1: + return result[0] + elif len(result) > 1: + return False + else: + return None + + @classmethod + def get_by_control_ci(cls, ci: str) -> ParticipantInDB | None | bool: + finds = cls.TABLE.find({"ci": ci, "disabled": False}) + items = [] + for find in finds: + items.append(ParticipantInDB(**find)) + + if len(items) == 1: + return items[0] + elif len(items) > 1: + return False + else: + return None + + @classmethod + def add_points(cls, id: PyObjectId, points: int) -> ParticipantInDB | None: + updated = cls.TABLE.find_one_and_update( + {"_id": id, "disabled": False}, + {"$inc": {"points": points}, "$set": {"date_update": datetime.utcnow()}}, + return_document=ReturnDocument.AFTER, + ) + if updated is not None: + return ParticipantInDB(**updated) + else: + return None + + @classmethod + def subtract_points(cls, id: PyObjectId, points: int) -> ParticipantInDB | None: + updated = cls.TABLE.find_one_and_update( + {"_id": id, "disabled": False}, + {"$inc": {"points": -points}, "$set": {"date_update": datetime.utcnow()}}, + return_document=ReturnDocument.AFTER, + ) + if updated is not None: + return ParticipantInDB(**updated) + else: + return None + + @classmethod + def get_by_ci(cls, ci: str) -> ParticipantInDB | None: + item = cls.TABLE.find_one({"ci": ci, "disabled": False}) + if item is not None: + return ParticipantInDB(**item) + return item + + @classmethod + def exist_control_code(cls, control_code: str) -> bool: + item = cls.TABLE.find_one({"control_code": control_code, "disabled": False}) + return item is not None + + @classmethod + def finish_registration( + cls, + id: PyObjectId, + control_code: str, + ) -> ParticipantInDB | None: + updated = cls.TABLE.find_one_and_update( + {"_id": id, "disabled": False}, + { + "$set": { + "control_code": control_code, + "registration": True, + "date_update": datetime.utcnow(), + "date_registration": datetime.utcnow(), + }, + }, + return_document=ReturnDocument.AFTER, + ) + if updated is not None: + return ParticipantInDB(**updated) + else: + return None + + @classmethod + def insert_many( + cls, + items: list[ParticipantInDB], + delete_id=True, + ) -> list[PyObjectId]: + for item in items: + if hasattr(item, "id") and delete_id: + delattr(item, "id") + if hasattr(item, "locations_items"): + delattr(item, "locations_items") + if hasattr(item, "combos_items"): + delattr(item, "combos_items") + item.date_insert = datetime.utcnow() + item.date_update = item.date_insert + inserted = cls.TABLE.insert_many([item.dict(by_alias=True) for item in items]) + return inserted.inserted_ids + + @classmethod + def finish_unregistration(cls, id: PyObjectId, user: str) -> ParticipantInDB | None: + updated = cls.TABLE.find_one_and_update( + {"_id": id, "disabled": False}, + { + "$set": { + "control_code": "", + "registration": False, + "date_update": datetime.utcnow(), + "username_update": user, + }, + }, + return_document=ReturnDocument.AFTER, + ) + if updated is not None: + return ParticipantInDB(**updated) + else: + return None + + @classmethod + def save_revoke(cls, id: PyObjectId, control_code: str, ci: str, user: str) -> bool: + cls.REVOKE_TABLE.insert_one( + { + "participant_id": id, + "control_code": control_code, + "ci": ci, + "date_insert": datetime.utcnow(), + "username_insert": user, + }, + ) + return True + + @classmethod + def get_by_ci_and_birthday(cls, ci: str, birthday: date) -> ParticipantInDB | None: + item = cls.TABLE.find_one( + { + "disabled": False, + "ci": ci, + "birthday": birthday, + }, + ) + if item is not None: + return ParticipantInDB(**item) + else: + return None diff --git a/app/services/point.py b/app/services/point.py index 43fcfeb..2cbebd8 100644 --- a/app/services/point.py +++ b/app/services/point.py @@ -1,71 +1,85 @@ -from datetime import datetime -from pymongo.collection import ReturnDocument - -from app.core.database import db -from app.models.point import PointInDB -from app.utils.mongo_validator import PyObjectId - - -class PointService: - TABLE = db.points - - @classmethod - def insert(cls, item: PointInDB) -> PointInDB: - if hasattr(item, "id"): - delattr(item, "id") - item.date_insert = datetime.utcnow() - item.date_update = item.date_insert - inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) - item.id = inserted.inserted_id - return item - - @classmethod - def delete(cls, id: PyObjectId) -> PointInDB | None: - deleted = cls.TABLE.find_one_and_update( - {"_id": id}, - {"$set": {"disabled": True}}, - return_document=ReturnDocument.AFTER, - ) - if deleted is not None: - return PointInDB(**deleted) - else: - return None - - @classmethod - def get(cls, id: PyObjectId) -> PointInDB | None: - item = cls.TABLE.find_one({"_id": id, "disabled": False}) - if item is not None: - return PointInDB(**item) - return item - - @classmethod - def get_all(cls) -> list[PointInDB]: - items = cls.TABLE.find({"disabled": False}) - return [PointInDB(**item) for item in items] - - @classmethod - def get_by_participant_and_presentation( - cls, participant_control_code: str, presentation_id: PyObjectId - ) -> PointInDB | None: - item = cls.TABLE.find_one( - { - "participant_control_code": participant_control_code, - "presentation": presentation_id, - "disabled": False, - "is_participation": False, - } - ) - if item is not None: - return PointInDB(**item) - return item - - @classmethod - def exist_participant_control_code( - cls, participant_control_code: str - ) -> bool: - items = cls.TABLE.count_documents( - {"participant_control_code": participant_control_code, "disabled": False} - ) - if items > 0: - return True - return False \ No newline at end of file +from datetime import datetime + +from pymongo.collection import ReturnDocument + +from app.core.database import db +from app.models.point import PointInDB +from app.utils.mongo_validator import PyObjectId + + +class PointService: + TABLE = db.points + + @classmethod + def insert(cls, item: PointInDB) -> PointInDB: + if hasattr(item, "id"): + delattr(item, "id") + item.date_insert = datetime.utcnow() + item.date_update = item.date_insert + inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) + item.id = inserted.inserted_id + return item + + @classmethod + def delete(cls, id: PyObjectId) -> PointInDB | None: + deleted = cls.TABLE.find_one_and_update( + {"_id": id}, + {"$set": {"disabled": True}}, + return_document=ReturnDocument.AFTER, + ) + if deleted is not None: + return PointInDB(**deleted) + else: + return None + + @classmethod + def get(cls, id: PyObjectId) -> PointInDB | None: + item = cls.TABLE.find_one({"_id": id, "disabled": False}) + if item is not None: + return PointInDB(**item) + return item + + @classmethod + def get_all(cls) -> list[PointInDB]: + items = cls.TABLE.find({"disabled": False}) + return [PointInDB(**item) for item in items] + + @classmethod + def get_by_participant_and_presentation( + cls, + participant_control_code: str, + presentation_id: PyObjectId, + ) -> PointInDB | None: + item = cls.TABLE.find_one( + { + "participant_control_code": participant_control_code, + "presentation": presentation_id, + "disabled": False, + "is_participation": False, + }, + ) + if item is not None: + return PointInDB(**item) + return item + + @classmethod + def exist_by_participant_and_checkpoint( + cls, + participant_control_code: str, + checkpoint_link_id: PyObjectId, + ) -> bool: + items = cls.TABLE.count_documents( + { + "participant_control_code": participant_control_code, + "checkpoint_link": checkpoint_link_id, + "disabled": False, + }, + ) + return items > 0 + + @classmethod + def exist_participant_control_code(cls, participant_control_code: str) -> bool: + items = cls.TABLE.count_documents( + {"participant_control_code": participant_control_code, "disabled": False}, + ) + return items > 0 diff --git a/app/services/presentation.py b/app/services/presentation.py index cf243ff..5993931 100644 --- a/app/services/presentation.py +++ b/app/services/presentation.py @@ -1,62 +1,65 @@ -from datetime import datetime -from pymongo.collection import ReturnDocument - -from app.core.database import db -from app.models.presentation import PresentationInDB -from app.utils.mongo_validator import PyObjectId - - -class PresentationService: - TABLE = db.presentations - - @classmethod - def insert(cls, item: PresentationInDB) -> PresentationInDB: - if hasattr(item, "id"): - delattr(item, "id") - item.date_insert = datetime.utcnow() - item.date_update = item.date_insert - inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) - item.id = inserted.inserted_id - return item - - @classmethod - def delete(cls, id: PyObjectId) -> PresentationInDB | None: - deleted = cls.TABLE.find_one_and_update( - {"_id": id}, - {"$set": {"disabled": True}}, - return_document=ReturnDocument.AFTER, - ) - if deleted is not None: - return PresentationInDB(**deleted) - else: - return None - - @classmethod - def get(cls, id: PyObjectId) -> PresentationInDB | None: - item = cls.TABLE.find_one({"_id": id, "disabled": False}) - if item is not None: - return PresentationInDB(**item) - return item - - @classmethod - def get_all(cls) -> list[PresentationInDB]: - items = cls.TABLE.find({"disabled": False}) - return [PresentationInDB(**item) for item in items] - - @classmethod - def get_all_talks(cls) -> list[PresentationInDB]: - items = cls.TABLE.find({"disabled": False, "kind": "talk"}) - return [PresentationInDB(**item) for item in items] - - @classmethod - def update_presentation( - cls, id: PyObjectId, points: int = 0 - ) -> PresentationInDB | None: - item = cls.TABLE.find_one_and_update( - {"_id": id, "disabled": False}, - {"$set": {"points": points}}, - return_document=ReturnDocument.AFTER, - ) - if item is not None: - return PresentationInDB(**item) - return item +from datetime import datetime + +from pymongo.collection import ReturnDocument + +from app.core.database import db +from app.models.presentation import PresentationInDB +from app.utils.mongo_validator import PyObjectId + + +class PresentationService: + TABLE = db.presentations + + @classmethod + def insert(cls, item: PresentationInDB) -> PresentationInDB: + if hasattr(item, "id"): + delattr(item, "id") + item.date_insert = datetime.utcnow() + item.date_update = item.date_insert + inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) + item.id = inserted.inserted_id + return item + + @classmethod + def delete(cls, id: PyObjectId) -> PresentationInDB | None: + deleted = cls.TABLE.find_one_and_update( + {"_id": id}, + {"$set": {"disabled": True}}, + return_document=ReturnDocument.AFTER, + ) + if deleted is not None: + return PresentationInDB(**deleted) + else: + return None + + @classmethod + def get(cls, id: PyObjectId) -> PresentationInDB | None: + item = cls.TABLE.find_one({"_id": id, "disabled": False}) + if item is not None: + return PresentationInDB(**item) + return item + + @classmethod + def get_all(cls) -> list[PresentationInDB]: + items = cls.TABLE.find({"disabled": False}) + return [PresentationInDB(**item) for item in items] + + @classmethod + def get_all_talks(cls) -> list[PresentationInDB]: + items = cls.TABLE.find({"disabled": False, "kind": "talk"}) + return [PresentationInDB(**item) for item in items] + + @classmethod + def update_presentation( + cls, + id: PyObjectId, + points: int = 0, + ) -> PresentationInDB | None: + item = cls.TABLE.find_one_and_update( + {"_id": id, "disabled": False}, + {"$set": {"points": points}}, + return_document=ReturnDocument.AFTER, + ) + if item is not None: + return PresentationInDB(**item) + return item diff --git a/app/services/presentation_checkpoint_link.py b/app/services/presentation_checkpoint_link.py new file mode 100644 index 0000000..4d48e53 --- /dev/null +++ b/app/services/presentation_checkpoint_link.py @@ -0,0 +1,183 @@ +from datetime import datetime, timedelta + +from pymongo.collection import ReturnDocument + +from app.core.database import db +from app.models.presentation import PresentationInDB +from app.models.presentation_checkpoint_link import PresentationCheckpointLinkInDB +from app.utils.link_code_generator import generate_unique_codes +from app.utils.mongo_validator import PyObjectId + + +class PresentationCheckpointLinkService: + TABLE = db.presentation_checkpoint_links + + @classmethod + def insert( + cls, + item: PresentationCheckpointLinkInDB, + ) -> PresentationCheckpointLinkInDB: + if hasattr(item, "id"): + delattr(item, "id") + item.date_insert = datetime.utcnow() + item.date_update = item.date_insert + inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) + item.id = inserted.inserted_id + return item + + @classmethod + def delete(cls, id: PyObjectId) -> PresentationCheckpointLinkInDB | None: + deleted = cls.TABLE.find_one_and_update( + {"_id": id}, + {"$set": {"disabled": True}}, + return_document=ReturnDocument.AFTER, + ) + if deleted is not None: + return PresentationCheckpointLinkInDB(**deleted) + else: + return None + + @classmethod + def update( + cls, + id: PyObjectId, + item: PresentationCheckpointLinkInDB, + ) -> PresentationCheckpointLinkInDB | None: + item.date_update = datetime.utcnow() + if hasattr(item, "id"): + delattr(item, "id") + if hasattr(item, "date_insert"): + delattr(item, "date_insert") + if hasattr(item, "username_insert"): + delattr(item, "username_insert") + if hasattr(item, "disabled"): + delattr(item, "disabled") + updated = cls.TABLE.find_one_and_update( + {"_id": id, "disabled": False}, + {"$set": item.dict(by_alias=True)}, + return_document=ReturnDocument.AFTER, + ) + if updated is not None: + return PresentationCheckpointLinkInDB(**updated) + else: + return None + + @classmethod + def get(cls, id: PyObjectId) -> PresentationCheckpointLinkInDB | None: + item = cls.TABLE.find_one({"_id": id, "disabled": False}) + if item is not None: + return PresentationCheckpointLinkInDB(**item) + return item + + @classmethod + def get_all(cls) -> list[PresentationCheckpointLinkInDB]: + items = cls.TABLE.find({"disabled": False}) + return [PresentationCheckpointLinkInDB(**item) for item in items] + + @classmethod + def get_by_code(cls, code: str) -> PresentationCheckpointLinkInDB | None: + item = cls.TABLE.find_one({"code": code, "disabled": False}) + if item is not None: + return PresentationCheckpointLinkInDB(**item) + return None + + @classmethod + def active_link( + cls, + id: PyObjectId, + start: datetime, + ) -> PresentationCheckpointLinkInDB | None: + item = cls.TABLE.find_one({"_id": id, "disabled": False}) + if item is None: + return None + + minutes = item.get("min_duration", 0) + end = start + timedelta(minutes=minutes) + update_data = { + "active": True, + "start_duration": start, + "end_duration": end, + "date_update": datetime.utcnow(), + } + + updated = cls.TABLE.find_one_and_update( + {"_id": id, "disabled": False}, + {"$set": update_data}, + return_document=ReturnDocument.AFTER, + ) + if updated is not None: + return PresentationCheckpointLinkInDB(**updated) + return None + + @classmethod + def deactivate_link(cls, id: PyObjectId) -> PresentationCheckpointLinkInDB | None: + item = cls.TABLE.find_one({"_id": id, "disabled": False}) + if item is None: + return None + + if item.get("active", True) is False: + return None + + update_data = { + "active": False, + "end_duration": datetime.utcnow(), + "date_update": datetime.utcnow(), + } + + updated = cls.TABLE.find_one_and_update( + {"_id": id, "disabled": False}, + {"$set": update_data}, + return_document=ReturnDocument.AFTER, + ) + if updated is not None: + return PresentationCheckpointLinkInDB(**updated) + return None + + @classmethod + def update_code( + cls, + id: PyObjectId, + code: str, + ) -> PresentationCheckpointLinkInDB | None: + updated = cls.TABLE.find_one_and_update( + {"_id": id, "disabled": False}, + {"$set": {"code": code, "date_update": datetime.utcnow()}}, + return_document=ReturnDocument.AFTER, + ) + if updated is not None: + return PresentationCheckpointLinkInDB(**updated) + return None + + @classmethod + async def generate_links( + cls, + presentation: PresentationInDB, + ) -> list[PresentationCheckpointLinkInDB]: + cls.TABLE.update_many( + {"presentation": presentation.id, "disabled": False}, + {"$set": {"disabled": True, "date_update": datetime.utcnow()}}, + ) + codes = generate_unique_codes( + cls.TABLE, + quantity=presentation.attendance_checkpoints, + column="code", + other_conditions={"disabled": False}, + ) + links = [] + for checkpoint, code in enumerate(codes, start=1): + link = PresentationCheckpointLinkInDB( + presentation=presentation.id, + checkpoint=checkpoint, + code=code, + active=False, + disabled=False, + min_duration=presentation.min_checkpoint_duration, + points=presentation.checkpoint_point(checkpoint), + date_insert=datetime.utcnow(), + ) + if hasattr(link, "id"): + delattr(link, "id") + links.append(link) + ids = cls.TABLE.insert_many([link.dict(by_alias=True) for link in links]) + links = cls.TABLE.find({"_id": {"$in": ids.inserted_ids}}) + return [PresentationCheckpointLinkInDB(**link) for link in links] diff --git a/app/services/product.py b/app/services/product.py index a9e02b7..7f56ad0 100644 --- a/app/services/product.py +++ b/app/services/product.py @@ -1,72 +1,187 @@ -from datetime import datetime -from pymongo.collection import ReturnDocument - -from app.core.database import db -from app.models.product import ProductInDB -from app.utils.mongo_validator import PyObjectId - - -class ProductService: - TABLE = db.products - - @classmethod - def insert(cls, item: ProductInDB) -> ProductInDB: - if hasattr(item, "id"): - delattr(item, "id") - item.date_insert = datetime.utcnow() - item.date_update = item.date_insert - inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) - item.id = inserted.inserted_id - return item - - @classmethod - def delete(cls, id: PyObjectId) -> ProductInDB | None: - deleted = cls.TABLE.find_one_and_update( - {"_id": id}, - {"$set": {"disabled": True}}, - return_document=ReturnDocument.AFTER, - ) - if deleted is not None: - return ProductInDB(**deleted) - else: - return None - - @classmethod - def update(cls, id: PyObjectId, item: ProductInDB) -> ProductInDB | None: - item.date_update = datetime.utcnow() - if hasattr(item, "id"): - delattr(item, "id") - if hasattr(item, "date_insert"): - delattr(item, "date_insert") - if hasattr(item, "username_insert"): - delattr(item, "username_insert") - if hasattr(item, "disabled"): - delattr(item, "disabled") - updated = cls.TABLE.find_one_and_update( - {"_id": id, "disabled": False}, - {"$set": item.dict(by_alias=True)}, - return_document=ReturnDocument.AFTER, - ) - if updated is not None: - return ProductInDB(**updated) - else: - return None - - @classmethod - def get(cls, id: PyObjectId) -> ProductInDB | None: - item = cls.TABLE.find_one({"_id": id, "disabled": False}) - if item is not None: - return ProductInDB(**item) - return item - - @classmethod - def get_enable_to_buy(cls, id: PyObjectId) -> ProductInDB | None: - item = cls.TABLE.find_one({"_id": id, "disabled": False, "able_to_buy": True}) - if item is not None: - return ProductInDB(**item) - return item - - @classmethod - def get_all(cls) -> list[ProductInDB]: - items = cls.TABLE.find({"disabled": False}) - return [ProductInDB(**item) for item in items] +from datetime import datetime + +from pymongo.collection import ReturnDocument + +from app.core.database import db +from app.models.product import DiscountCode, ProductInDB +from app.utils.mongo_validator import PyObjectId + + +class ProductService: + TABLE = db.products + + @classmethod + def insert(cls, item: ProductInDB) -> ProductInDB: + if hasattr(item, "id"): + delattr(item, "id") + if item.kind == "discount_code": + item.stock = sum(1 for c in item.discount_codes if c.assigned_to is None) + item.date_insert = datetime.utcnow() + item.date_update = item.date_insert + inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) + item.id = inserted.inserted_id + return item + + @classmethod + def delete(cls, id: PyObjectId) -> ProductInDB | None: + deleted = cls.TABLE.find_one_and_update( + {"_id": id}, + {"$set": {"disabled": True}}, + return_document=ReturnDocument.AFTER, + ) + if deleted is not None: + return ProductInDB(**deleted) + else: + return None + + @classmethod + def update(cls, id: PyObjectId, item: ProductInDB) -> ProductInDB | None: + item.date_update = datetime.utcnow() + if hasattr(item, "id"): + delattr(item, "id") + if hasattr(item, "date_insert"): + delattr(item, "date_insert") + if hasattr(item, "username_insert"): + delattr(item, "username_insert") + if hasattr(item, "disabled"): + delattr(item, "disabled") + # Codes and their stock are managed only through add_codes/assign_code + exclude = {"discount_codes"} + if item.kind == "discount_code": + exclude.add("stock") + updated = cls.TABLE.find_one_and_update( + {"_id": id, "disabled": False}, + {"$set": item.dict(by_alias=True, exclude=exclude)}, + return_document=ReturnDocument.AFTER, + ) + if updated is not None: + return ProductInDB(**updated) + else: + return None + + @classmethod + def get(cls, id: PyObjectId) -> ProductInDB | None: + item = cls.TABLE.find_one({"_id": id, "disabled": False}) + if item is not None: + return ProductInDB(**item) + return item + + @classmethod + def get_enable_to_buy(cls, id: PyObjectId) -> ProductInDB | None: + item = cls.TABLE.find_one({"_id": id, "disabled": False, "able_to_buy": True}) + if item is not None: + return ProductInDB(**item) + return item + + @classmethod + def get_all(cls) -> list[ProductInDB]: + items = cls.TABLE.find({"disabled": False}) + return [ProductInDB(**item) for item in items] + + @classmethod + def get_virtual_meal(cls) -> ProductInDB | None: + item = cls.TABLE.find_one( + { + "disabled": False, + "is_virtual_meal": True, + "kind": {"$ne": "physical"}, + "$or": [{"stock": None}, {"stock": {"$gt": 0}}], + }, + ) + if item is not None: + return ProductInDB(**item) + return None + + @classmethod + def reserve_stock(cls, id: PyObjectId) -> bool: + item = cls.TABLE.find_one({"_id": id, "disabled": False}) + if item is None: + return False + if item.get("stock") is None: + return True + updated = cls.TABLE.find_one_and_update( + {"_id": id, "disabled": False, "stock": {"$gt": 0}}, + {"$inc": {"stock": -1}, "$set": {"date_update": datetime.utcnow()}}, + ) + return updated is not None + + @classmethod + def release_stock(cls, id: PyObjectId) -> None: + cls.TABLE.update_one( + {"_id": id, "stock": {"$ne": None}}, + {"$inc": {"stock": 1}, "$set": {"date_update": datetime.utcnow()}}, + ) + + @classmethod + def add_codes(cls, id: PyObjectId, codes: list[str]) -> ProductInDB | None: + item = cls.TABLE.find_one({"_id": id, "disabled": False}) + if item is None: + return None + current = item.get("discount_codes", []) + existing = {c["code"] for c in current} + new_codes = [] + for code in codes: + code = code.strip() + if code and code not in existing: + existing.add(code) + new_codes.append(DiscountCode(code=code).dict()) + if item.get("stock") is None: + available = sum(1 for c in current if c.get("assigned_to") is None) + cls.TABLE.update_one( + {"_id": id, "stock": None}, + {"$set": {"stock": available}}, + ) + updated = cls.TABLE.find_one_and_update( + {"_id": id, "disabled": False}, + { + "$push": {"discount_codes": {"$each": new_codes}}, + "$inc": {"stock": len(new_codes)}, + "$set": {"date_update": datetime.utcnow()}, + }, + return_document=ReturnDocument.AFTER, + ) + if updated is not None: + return ProductInDB(**updated) + return None + + @classmethod + def assign_code(cls, id: PyObjectId, control_code: str) -> str | None: + now = datetime.utcnow() + # Positional $ targets the first unassigned code, so BEFORE tells us which one + before = cls.TABLE.find_one_and_update( + { + "_id": id, + "disabled": False, + "stock": {"$gt": 0}, + "discount_codes": {"$elemMatch": {"assigned_to": None}}, + }, + { + "$set": { + "discount_codes.$.assigned_to": control_code, + "discount_codes.$.date_assigned": now, + "date_update": now, + }, + "$inc": {"stock": -1}, + }, + projection={"discount_codes": 1}, + return_document=ReturnDocument.BEFORE, + ) + if before is None: + return None + return next( + c["code"] for c in before["discount_codes"] if c.get("assigned_to") is None + ) + + @classmethod + def release_code(cls, id: PyObjectId, code: str) -> None: + cls.TABLE.update_one( + {"_id": id, "discount_codes.code": code}, + { + "$set": { + "discount_codes.$.assigned_to": None, + "discount_codes.$.date_assigned": None, + "date_update": datetime.utcnow(), + }, + "$inc": {"stock": 1}, + }, + ) diff --git a/app/services/redemption.py b/app/services/redemption.py new file mode 100644 index 0000000..1641979 --- /dev/null +++ b/app/services/redemption.py @@ -0,0 +1,137 @@ +from datetime import datetime + +from fastapi import status +from pymongo.errors import DuplicateKeyError + +from app.models.gift import RedeemGiftInDB +from app.models.meal import RedeemMealInDB +from app.models.participant import ParticipantInDB +from app.models.product import ProductInDB +from app.services.gift import GiftService, RedeemGiftService +from app.services.meal import RedeemMealService +from app.services.participant import ParticipantService +from app.services.product import ProductService +from app.utils.mailer import send_email +from app.utils.mongo_validator import PyObjectId + + +class RedemptionError(Exception): + def __init__(self, message: str, status_code: int = status.HTTP_400_BAD_REQUEST): + super().__init__(message) + self.message = message + self.status_code = status_code + + +def _deliver(product: ProductInDB, control_code: str) -> str | None: + """Reserve one unit of stock; returns the assigned discount code, if any.""" + if product.kind == "discount_code": + code = ProductService.assign_code(product.id, control_code) + if code is None: + raise RedemptionError("Out of stock") + return code + if not ProductService.reserve_stock(product.id): + raise RedemptionError("Out of stock") + return None + + +def _download_url(product: ProductInDB) -> str | None: + if product.kind == "digital_file" and product.file_url: + return product.file_url + return None + + +def _release(product: ProductInDB, discount_code: str | None) -> None: + if discount_code: + ProductService.release_code(product.id, discount_code) + else: + ProductService.release_stock(product.id) + + +def redeem_gift( + participant: ParticipantInDB, + gift_id: PyObjectId, + user_control: str, + virtual: bool = False, +) -> tuple[RedeemGiftInDB, ProductInDB]: + gift = GiftService.get(gift_id) + if gift is None: + raise RedemptionError("Gift not found", status.HTTP_404_NOT_FOUND) + if not gift.multiple and RedeemGiftService.get_redeem_gift_by_participant( + participant.control_code, + gift_id, + ): + raise RedemptionError("Gift already redeemed") + if participant.points < gift.points: + raise RedemptionError( + f"Not enough points, participant has {participant.points} points", + ) + product = ProductService.get(gift.product) + if product is None: + raise RedemptionError("Product not found", status.HTTP_404_NOT_FOUND) + if virtual and product.kind == "physical": + raise RedemptionError("Gift not available for virtual redemption") + + discount_code = _deliver(product, participant.control_code) + redeem = RedeemGiftInDB( + participant_control_code=participant.control_code, + gift=gift_id, + date=datetime.now(), + user_control=user_control, + username_insert=user_control, + product=product.id, + discount_code=discount_code, + download_url=_download_url(product), + multiple=gift.multiple, + ) + try: + inserted = RedeemGiftService.insert(redeem) + except DuplicateKeyError: + _release(product, discount_code) + raise RedemptionError("Gift already redeemed") + ParticipantService.subtract_points(participant.id, gift.points) + return inserted, product + + +def redeem_virtual_meal( + participant: ParticipantInDB, + user_control: str, +) -> tuple[RedeemMealInDB, ProductInDB]: + if RedeemMealService.get_by_participant(participant.control_code): + raise RedemptionError("Participant already redeemed meal") + product = ProductService.get_virtual_meal() + if product is None: + raise RedemptionError("Virtual meal not available", status.HTTP_404_NOT_FOUND) + + discount_code = _deliver(product, participant.control_code) + redeem = RedeemMealInDB( + participant_control_code=participant.control_code, + date=datetime.now(), + user_control=user_control, + username_insert=user_control, + product=product.id, + discount_code=discount_code, + download_url=_download_url(product), + ) + try: + return RedeemMealService.insert(redeem), product + except DuplicateKeyError: + _release(product, discount_code) + raise RedemptionError("Participant already redeemed meal") + + +def send_redeem_email( + participant: ParticipantInDB, + product: ProductInDB, + discount_code: str | None, + download_url: str | None, +) -> None: + if not discount_code and not download_url: + return + lines = [f"Hola {participant.name},", "", f"Canjeaste: {product.name}"] + if product.description: + lines.append(product.description) + if discount_code: + lines.append(f"Tu código: {discount_code}") + if download_url: + lines.append(f"Descarga tu archivo aquí: {download_url}") + send_email(participant.mail, f"Tu canje: {product.name}", "\n".join(lines)) diff --git a/app/services/special_point.py b/app/services/special_point.py index 41ee04e..0574f16 100644 --- a/app/services/special_point.py +++ b/app/services/special_point.py @@ -1,80 +1,81 @@ -from datetime import datetime -from typing import List -from pymongo.collection import ReturnDocument - -from app.core.database import db -from app.models.special_point import SpecialPointInDB, SpecialPointRedeemInDB -from app.utils.mongo_validator import PyObjectId - - -class SpecialPointService: - TABLE = db.special_points - TABLE_REDEEM = db.special_points_redeem - - @classmethod - def insert(cls, item: SpecialPointInDB) -> SpecialPointInDB: - if hasattr(item, "id"): - delattr(item, "id") - item.date_insert = datetime.utcnow() - item.date_update = item.date_insert - inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) - item.id = inserted.inserted_id - return item - - @classmethod - def delete(cls, id: PyObjectId) -> SpecialPointInDB | None: - deleted = cls.TABLE.find_one_and_update( - {"_id": id}, - {"$set": {"disabled": True}}, - return_document=ReturnDocument.AFTER, - ) - if deleted is not None: - return SpecialPointInDB(**deleted) - else: - return None - - @classmethod - def get(cls, id: PyObjectId) -> SpecialPointInDB | None: - item = cls.TABLE.find_one({"_id": id, "disabled": False}) - if item is not None: - return SpecialPointInDB(**item) - return item - - @classmethod - def get_all(cls) -> list[SpecialPointInDB]: - items = cls.TABLE.find({"disabled": False}) - return [SpecialPointInDB(**item) for item in items] - - @classmethod - def insert_redeem(cls, item: SpecialPointRedeemInDB) -> SpecialPointRedeemInDB: - if hasattr(item, "id"): - delattr(item, "id") - item.date_insert = datetime.utcnow() - item.date_update = item.date_insert - inserted = cls.TABLE_REDEEM.insert_one(item.dict(by_alias=True)) - item.id = inserted.inserted_id - return item - - @classmethod - def get_redeems( - cls, participant_control_code: str, id: PyObjectId - ) -> List[SpecialPointRedeemInDB] | None: - item = cls.TABLE_REDEEM.find( - { - "participant_control_code": participant_control_code, - "special_point": id, - "disabled": False, - } - ) - if item is not None: - return [SpecialPointRedeemInDB(**i) for i in item] - return item - - @classmethod - def exist_participant_control_code(cls, participant_control_code: str) -> bool: - items = cls.TABLE_REDEEM.count_documents( - {"participant_control_code": participant_control_code, "disabled": False} - ) - if items > 0: - return True - return False +from datetime import datetime +from typing import List + +from pymongo.collection import ReturnDocument + +from app.core.database import db +from app.models.special_point import SpecialPointInDB, SpecialPointRedeemInDB +from app.utils.mongo_validator import PyObjectId + + +class SpecialPointService: + TABLE = db.special_points + TABLE_REDEEM = db.special_points_redeem + + @classmethod + def insert(cls, item: SpecialPointInDB) -> SpecialPointInDB: + if hasattr(item, "id"): + delattr(item, "id") + item.date_insert = datetime.utcnow() + item.date_update = item.date_insert + inserted = cls.TABLE.insert_one(item.dict(by_alias=True)) + item.id = inserted.inserted_id + return item + + @classmethod + def delete(cls, id: PyObjectId) -> SpecialPointInDB | None: + deleted = cls.TABLE.find_one_and_update( + {"_id": id}, + {"$set": {"disabled": True}}, + return_document=ReturnDocument.AFTER, + ) + if deleted is not None: + return SpecialPointInDB(**deleted) + else: + return None + + @classmethod + def get(cls, id: PyObjectId) -> SpecialPointInDB | None: + item = cls.TABLE.find_one({"_id": id, "disabled": False}) + if item is not None: + return SpecialPointInDB(**item) + return item + + @classmethod + def get_all(cls) -> list[SpecialPointInDB]: + items = cls.TABLE.find({"disabled": False}) + return [SpecialPointInDB(**item) for item in items] + + @classmethod + def insert_redeem(cls, item: SpecialPointRedeemInDB) -> SpecialPointRedeemInDB: + if hasattr(item, "id"): + delattr(item, "id") + item.date_insert = datetime.utcnow() + item.date_update = item.date_insert + inserted = cls.TABLE_REDEEM.insert_one(item.dict(by_alias=True)) + item.id = inserted.inserted_id + return item + + @classmethod + def get_redeems( + cls, + participant_control_code: str, + id: PyObjectId, + ) -> List[SpecialPointRedeemInDB] | None: + item = cls.TABLE_REDEEM.find( + { + "participant_control_code": participant_control_code, + "special_point": id, + "disabled": False, + }, + ) + if item is not None: + return [SpecialPointRedeemInDB(**i) for i in item] + return item + + @classmethod + def exist_participant_control_code(cls, participant_control_code: str) -> bool: + items = cls.TABLE_REDEEM.count_documents( + {"participant_control_code": participant_control_code, "disabled": False}, + ) + return items > 0 diff --git a/app/services/user.py b/app/services/user.py index 1dfb6a7..8fdeb85 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -1,82 +1,83 @@ -from datetime import datetime -from pymongo.collection import ReturnDocument - -from app.core.database import db -from app.models.user import User, UserInDB - - -class UserService: - TABLE = db.users - - @classmethod - def get_user(cls, username: str) -> UserInDB | None: - ret = cls.TABLE.find_one({"username": username}) - if ret is not None: - return UserInDB(**ret) - else: - return None - - @classmethod - def get_user_active(cls, username: str) -> UserInDB | None: - ret = cls.TABLE.find_one({"username": username, "disabled": False}) - if ret is not None: - return UserInDB(**ret) - else: - return None - - @classmethod - def create_user(cls, user: UserInDB) -> User: - user.date_insert = datetime.utcnow() - user.disabled = False - user.id = cls.TABLE.insert_one(user.dict()).inserted_id - inserted = User(**user.dict()) - inserted.id = user.id - return inserted - - @classmethod - def set_admin(cls, username: str, admin: bool) -> User | None: - ret = cls.TABLE.find_one_and_update( - {"username": username, "disabled": False}, - {"$set": {"admin": admin}}, - return_document=ReturnDocument.AFTER, - ) - if ret is not None: - return User(**ret) - else: - return None - - @classmethod - def set_editor(cls, username: str, editor: bool) -> User | None: - ret = cls.TABLE.find_one_and_update( - {"username": username, "disabled": False}, - {"$set": {"editor": editor}}, - return_document=ReturnDocument.AFTER, - ) - if ret is not None: - return User(**ret) - else: - return None - - @classmethod - def set_password(cls, username: str, hashed_password: str) -> User | None: - ret = cls.TABLE.find_one_and_update( - {"username": username, "disabled": False}, - {"$set": {"hashed_password": hashed_password}}, - return_document=ReturnDocument.AFTER, - ) - if ret is not None: - return User(**ret) - else: - return None - - @classmethod - def delete_user(cls, username: str) -> User | None: - ret = cls.TABLE.find_one_and_update( - {"username": username, "disabled": False}, - {"$set": {"disabled": True}}, - return_document=ReturnDocument.AFTER, - ) - if ret is not None: - return User(**ret) - else: - return None +from datetime import datetime + +from pymongo.collection import ReturnDocument + +from app.core.database import db +from app.models.user import User, UserInDB + + +class UserService: + TABLE = db.users + + @classmethod + def get_user(cls, username: str) -> UserInDB | None: + ret = cls.TABLE.find_one({"username": username}) + if ret is not None: + return UserInDB(**ret) + else: + return None + + @classmethod + def get_user_active(cls, username: str) -> UserInDB | None: + ret = cls.TABLE.find_one({"username": username, "disabled": False}) + if ret is not None: + return UserInDB(**ret) + else: + return None + + @classmethod + def create_user(cls, user: UserInDB) -> User: + user.date_insert = datetime.utcnow() + user.disabled = False + user.id = cls.TABLE.insert_one(user.dict()).inserted_id + inserted = User(**user.dict()) + inserted.id = user.id + return inserted + + @classmethod + def set_admin(cls, username: str, admin: bool) -> User | None: + ret = cls.TABLE.find_one_and_update( + {"username": username, "disabled": False}, + {"$set": {"admin": admin}}, + return_document=ReturnDocument.AFTER, + ) + if ret is not None: + return User(**ret) + else: + return None + + @classmethod + def set_editor(cls, username: str, editor: bool) -> User | None: + ret = cls.TABLE.find_one_and_update( + {"username": username, "disabled": False}, + {"$set": {"editor": editor}}, + return_document=ReturnDocument.AFTER, + ) + if ret is not None: + return User(**ret) + else: + return None + + @classmethod + def set_password(cls, username: str, hashed_password: str) -> User | None: + ret = cls.TABLE.find_one_and_update( + {"username": username, "disabled": False}, + {"$set": {"hashed_password": hashed_password}}, + return_document=ReturnDocument.AFTER, + ) + if ret is not None: + return User(**ret) + else: + return None + + @classmethod + def delete_user(cls, username: str) -> User | None: + ret = cls.TABLE.find_one_and_update( + {"username": username, "disabled": False}, + {"$set": {"disabled": True}}, + return_document=ReturnDocument.AFTER, + ) + if ret is not None: + return User(**ret) + else: + return None diff --git a/app/utils/control_code_generator.py b/app/utils/control_code_generator.py new file mode 100644 index 0000000..a029593 --- /dev/null +++ b/app/utils/control_code_generator.py @@ -0,0 +1,164 @@ +import random + +LEFT = [ + "admirable", + "alegre", + "amable", + "audaz", + "brillante", + "calido", + "capaz", + "sereno", + "valiente", + "firme", + "fuerte", + "noble", + "libre", + "activo", + "agil", + "claro", + "tranquilo", + "honesto", + "justo", + "leal", + "amigable", + "creativo", + "curioso", + "decidido", + "dinamico", + "energetico", + "entusiasta", + "feliz", + "generoso", + "gentil", + "gracioso", + "inspirado", + "optimista", + "paciente", + "positivo", + "practico", + "rapido", + "respetuoso", + "sabio", + "sincero", + "solidario", + "sonriente", + "tranquilo", + "unico", + "vibrante", + "vigoroso", + "amistoso", + "bravo", + "despierto", + "lucido", + "radiante", + "tenaz", + "prudente", + "atento", + "ingenioso", + "persistente", + "proactivo", + "resuelto", + "seguro", + "sutil", + "constante", +] + + +RIGHT = [ + # La Paz y Bolivia + "illimani", + "huayna", + "mururata", + "sajama", + "titicaca", + "copacabana", + "chacaltaya", + "condor", + "kantuta", + "achachila", + "altiplano", + "andes", + "yungas", + "amazonas", + "salar", + "urucuru", + "quinua", + "llama", + "alpaca", + "vicuna", + # Naturaleza + "puma", + "jaguar", + "zorro", + "aguila", + "halcon", + "colibri", + "flamenco", + "condor", + "vizcacha", + "taruca", + "oso", + "mono", + "delfin", + "tucan", + # Cultura / historia + "tiwanaku", + "inca", + "aymara", + "quechua", + "chacana", + "pachamama", + "apacheta", + "wiphala", + "sikuri", + "charango", + "zampoña", + "quena", + "tinku", + "caporal", + "morenada", + "diablada", + "cueca", + # Elementos naturales + "sol", + "luna", + "estrella", + "aurora", + "cielo", + "nube", + "rayo", + "trueno", + "viento", + "brisa", + "fuego", + "rio", + "lago", + "montana", + "valle", + "bosque", + "flor", + "piedra", + "roble", + "cedro", + "coral", + "jade", + "oro", + "plata", +] + + +def generate_name() -> str: + """ + Genera un identificador al estilo Docker: + + adjetivo-nombre-digito + + Ejemplo: + + valiente-illimani-7 + """ + + left = random.choice(LEFT) + right = random.choice(RIGHT) + return f"{left}-{right}".lower() diff --git a/app/utils/link_code_generator.py b/app/utils/link_code_generator.py new file mode 100644 index 0000000..03598fd --- /dev/null +++ b/app/utils/link_code_generator.py @@ -0,0 +1,54 @@ +import secrets +import string + +ALPHABET = string.ascii_uppercase + string.digits + + +def generate_code( + min_length: int = 5, + max_length: int = 5, +) -> str: + length = secrets.randbelow(max_length - min_length + 1) + min_length + + return "".join(secrets.choice(ALPHABET) for _ in range(length)) + + +def validate_codes( + collection, + codes: list[str], + column: str = "code", + other_conditions: dict | None = None, +) -> bool: + if len(codes) != len(set(codes)): + return False + existing = collection.find_one( + {column: {"$in": codes}, **(other_conditions or {})}, + {"_id": 1}, + ) + return existing is None + + +def generate_unique_codes( + collection, + quantity: int, + column: str = "code", + other_conditions: dict | None = None, +) -> list[str]: + codes = [generate_code() for _ in range(quantity)] + while True: + seen = set() + duplicates = set() + for code in codes: + if code in seen: + duplicates.add(code) + seen.add(code) + for index, code in enumerate(codes): + if code in duplicates: + codes[index] = generate_code() + if validate_codes( + collection, + codes, + column=column, + other_conditions=other_conditions, + ): + return codes diff --git a/app/utils/mailer.py b/app/utils/mailer.py new file mode 100644 index 0000000..53315c6 --- /dev/null +++ b/app/utils/mailer.py @@ -0,0 +1,31 @@ +import logging +import smtplib +from email.message import EmailMessage + +from app.core import configuration + +logger = logging.getLogger(__name__) + + +def send_email(to: str, subject: str, body: str) -> None: + if not configuration.APP_SMTP_HOST or not to: + logger.warning("Email not sent to %s: SMTP not configured or no address", to) + return + message = EmailMessage() + message["From"] = configuration.APP_SMTP_FROM or configuration.APP_SMTP_USER + message["To"] = to + message["Subject"] = subject + message.set_content(body) + try: + with smtplib.SMTP( + configuration.APP_SMTP_HOST, + configuration.APP_SMTP_PORT, + timeout=30, + ) as smtp: + if configuration.APP_SMTP_STARTTLS: + smtp.starttls() + if configuration.APP_SMTP_USER: + smtp.login(configuration.APP_SMTP_USER, configuration.APP_SMTP_PASSWORD) + smtp.send_message(message) + except Exception: + logger.exception("Failed to send email to %s", to) diff --git a/app/utils/mongo_validator.py b/app/utils/mongo_validator.py index 556be6a..98e8c0f 100644 --- a/app/utils/mongo_validator.py +++ b/app/utils/mongo_validator.py @@ -1,17 +1,17 @@ -from bson import ObjectId - - -class PyObjectId(ObjectId): - @classmethod - def __get_validators__(cls): - yield cls.validate - - @classmethod - def validate(cls, v): - if not ObjectId.is_valid(v): - raise ValueError("Invalid objectid") - return ObjectId(v) - - @classmethod - def __modify_schema__(cls, field_schema): - field_schema.update(type="string") +from bson import ObjectId + + +class PyObjectId(ObjectId): + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, v): + if not ObjectId.is_valid(v): + raise ValueError("Invalid objectid") + return ObjectId(v) + + @classmethod + def __modify_schema__(cls, field_schema): + field_schema.update(type="string") diff --git a/app/utils/qr_generator.py b/app/utils/qr_generator.py new file mode 100644 index 0000000..650ddb2 --- /dev/null +++ b/app/utils/qr_generator.py @@ -0,0 +1,69 @@ +import base64 +import io + +import qrcode +from PIL import Image, ImageDraw, ImageFont +from qrcode.constants import ERROR_CORRECT_H +from qrcode.image.styledpil import StyledPilImage +from qrcode.image.styles.colormasks import SolidFillColorMask +from qrcode.image.styles.moduledrawers import RoundedModuleDrawer + + +def generate_qr(value: str): + fill_color = (25, 55, 90) + background_color = (255, 255, 255) + qr = qrcode.QRCode( + version=None, + error_correction=ERROR_CORRECT_H, + box_size=12, + border=2, + ) + + qr.add_data(value) + qr.make(fit=True) + + qr_image = qr.make_image( + image_factory=StyledPilImage, + module_drawer=RoundedModuleDrawer(), + color_mask=SolidFillColorMask( + front_color=fill_color, + back_color=background_color, + ), + ).convert("RGB") + + title_height = 20 + image = Image.new( + "RGB", + (qr_image.width, qr_image.height + title_height), + background_color, + ) + image.paste(qr_image, (0, 0)) + draw = ImageDraw.Draw(image) + + font = ImageFont.load_default(size=18) + title = value.lower() + + bbox = draw.textbbox((0, 0), title, font=font, stroke_width=0.1, spacing=6) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + text_x = (image.width - text_width) // 2 + text_y = (qr_image.height + (title_height - text_height) // 2) - 20 + + draw.text( + (text_x, text_y), + title, + fill=fill_color, + font=font, + stroke_width=0.1, + spacing=6, + ) + + return image + + +def qr_to_base64(value: str) -> str: + image = generate_qr(value) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + text = base64.b64encode(buffer.getvalue()).decode("ascii") + return f"data:image/png;base64,{text}" diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index 8bd0d61..93c51e3 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -1,9 +1,9 @@ -version: '3' -services: - app: - build: - context: . - env_file: - - .env - ports: - - "8000:8000" \ No newline at end of file +version: '3' +services: + app: + build: + context: . + env_file: + - .env + ports: + - "8000:8000" diff --git a/docker-compose.traefik.yaml b/docker-compose.traefik.yaml index 2087e4a..84ea053 100644 --- a/docker-compose.traefik.yaml +++ b/docker-compose.traefik.yaml @@ -1,19 +1,19 @@ -version: '3.9' -services: - app: - image: gcr.io/jevillanueva/pythonlapaz/control-api:latest - restart: always - labels: - - traefik.http.routers.${TRAEFIK_ROUTE}.rule=Host(${TRAEFIK_APP_DOMAIN}) - - traefik.http.routers.${TRAEFIK_ROUTE}.tls=true - - traefik.http.routers.${TRAEFIK_ROUTE}.tls.certresolver=lets-encrypt - - traefik.port=${TRAEFIK_APP_PORT} - env_file: - - .env - networks: - - web - expose: - - "8000" -networks: - web: - external: true \ No newline at end of file +version: '3.9' +services: + app: + image: gcr.io/jevillanueva/pythonlapaz/control-api:latest + restart: always + labels: + - traefik.http.routers.${TRAEFIK_ROUTE}.rule=Host(${TRAEFIK_APP_DOMAIN}) + - traefik.http.routers.${TRAEFIK_ROUTE}.tls=true + - traefik.http.routers.${TRAEFIK_ROUTE}.tls.certresolver=lets-encrypt + - traefik.port=${TRAEFIK_APP_PORT} + env_file: + - .env + networks: + - web + expose: + - "8000" +networks: + web: + external: true diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..b69e94e --- /dev/null +++ b/requirements.in @@ -0,0 +1,17 @@ +bcrypt==4.0.1 +cryptography==41.0.0 +fastapi==0.95.2 +httptools==0.8.0 +passlib==1.7.4 +pymongo==4.3.3 +python-dotenv==1.0.0 +python-jose==3.3.0 +python-multipart==0.0.6 +requests==2.32.3 +uvicorn==0.22.0 +watchfiles==0.19.0 +websockets==11.0.3 +colorama==0.4.6 +pre-commit==4.6.2 +ruff==0.16.8 +qrcode[pil]==8.2 diff --git a/requirements.txt b/requirements.txt index 5d896a5..036f286 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,31 +1,104 @@ -anyio==3.7.0 -bcrypt==4.0.1 -cffi==1.15.1 -click==8.1.3 -colorama==0.4.6 -cryptography==41.0.0 -dnspython==2.3.0 -ecdsa==0.18.0 -exceptiongroup==1.1.1 -fastapi==0.95.2 -h11==0.14.0 -httptools==0.5.0 -idna==3.4 -passlib==1.7.4 -pyasn1==0.5.0 -pycparser==2.21 -pydantic==1.10.8 -pymongo==4.3.3 -python-dotenv==1.0.0 -python-jose==3.3.0 -python-multipart==0.0.6 -rsa==4.9 -six==1.16.0 -sniffio==1.3.0 -starlette==0.27.0 -typing_extensions==4.6.2 -uvicorn==0.22.0 -watchfiles==0.19.0 -websockets==11.0.3 -requests==2.32.3 -PyYAML==6.0.2 \ No newline at end of file +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile --no-index +# +anyio==4.15.1 + # via + # starlette + # watchfiles +bcrypt==4.0.1 + # via -r requirements.in +certifi==2026.7.22 + # via requests +cffi==2.1.1 + # via cryptography +cfgv==3.5.0 + # via pre-commit +charset-normalizer==3.5.1 + # via requests +click==8.5.0 + # via uvicorn +colorama==0.4.6 + # via -r requirements.in +cryptography==41.0.0 + # via -r requirements.in +distlib==0.4.3 + # via virtualenv +dnspython==2.8.0 + # via pymongo +ecdsa==0.19.2 + # via python-jose +fastapi==0.95.2 + # via -r requirements.in +filelock==4.0.3 + # via + # python-discovery + # virtualenv +h11==0.16.0 + # via uvicorn +httptools==0.8.0 + # via -r requirements.in +identify==2.6.19 + # via pre-commit +idna==3.20 + # via + # anyio + # requests +nodeenv==1.10.0 + # via pre-commit +passlib==1.7.4 + # via -r requirements.in +pillow==12.3.0 + # via qrcode +platformdirs==4.11.12 + # via virtualenv +pre-commit==4.6.2 + # via -r requirements.in +pyasn1==0.6.4 + # via + # python-jose + # rsa +pycparser==3.0 + # via cffi +pydantic==1.10.26 + # via fastapi +pymongo==4.3.3 + # via -r requirements.in +python-discovery==1.6.1 + # via virtualenv +python-dotenv==1.0.0 + # via -r requirements.in +python-jose==3.3.0 + # via -r requirements.in +python-multipart==0.0.6 + # via -r requirements.in +pyyaml==6.0.3 + # via pre-commit +qrcode[pil]==8.2 + # via -r requirements.in +requests==2.32.3 + # via -r requirements.in +rsa==4.9.1 + # via python-jose +ruff==0.16.8 + # via -r requirements.in +six==1.17.0 + # via ecdsa +starlette==0.27.0 + # via fastapi +typing-extensions==4.16.0 + # via + # anyio + # pydantic +urllib3==2.8.0 + # via requests +uvicorn==0.22.0 + # via -r requirements.in +virtualenv==21.12.1 + # via pre-commit +watchfiles==0.19.0 + # via -r requirements.in +websockets==11.0.3 + # via -r requirements.in From 9e44ef611e4d519068eb1bd7dee53ef60a6b4db6 Mon Sep 17 00:00:00 2001 From: Jonathan Villanueva Date: Tue, 6 Oct 2026 00:04:36 +0000 Subject: [PATCH 2/7] feat: refactor redemption logic and update endpoint names for rewards --- .vscode/settings.json | 3 ++ app/routers/gifts.py | 4 +-- app/routers/self_service.py | 59 +++++++++++++++++++++++++++++-------- app/services/redemption.py | 16 +++++----- 4 files changed, 60 insertions(+), 22 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2fb1fda --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:system" +} diff --git a/app/routers/gifts.py b/app/routers/gifts.py index 13921e6..a48b46e 100644 --- a/app/routers/gifts.py +++ b/app/routers/gifts.py @@ -9,7 +9,7 @@ from app.models.user import UserInDB from app.services.gift import GiftService, RedeemGiftService from app.services.participant import ParticipantService -from app.services.redemption import RedemptionError, redeem_gift, send_redeem_email +from app.services.redemption import RedemptionError, redeem_reward, send_redeem_email from app.utils.mongo_validator import PyObjectId router = APIRouter() @@ -108,7 +108,7 @@ async def redeem_point( content="Participant not found", ) try: - redeem, product = redeem_gift(participant, gift_id, current_user.username) + redeem, product = redeem_reward(participant, gift_id, current_user.username) except RedemptionError as error: return HTMLResponse(status_code=error.status_code, content=error.message) background_tasks.add_task( diff --git a/app/routers/self_service.py b/app/routers/self_service.py index fb0182a..dd4db86 100644 --- a/app/routers/self_service.py +++ b/app/routers/self_service.py @@ -23,10 +23,11 @@ from app.services.point import PointService from app.services.presentation import PresentationService from app.services.presentation_checkpoint_link import PresentationCheckpointLinkService +from app.services.product import ProductService from app.services.redemption import ( RedemptionError, - redeem_gift, - redeem_virtual_meal, + redeem_reward, + redeem_virtual_gift, send_redeem_email, ) from app.utils.control_code_generator import generate_name @@ -210,6 +211,27 @@ async def self_service_participation_register( ) +@router.post("/points", response_model=SelfServiceResult) +async def self_service_points(points_request: SelfServiceCheckRequest): + control_code = points_request.control_code + participant = ParticipantService.get_by_control_code(control_code) + if participant is None: + return HTMLResponse( + content="Participant not found", + status_code=status.HTTP_404_NOT_FOUND, + ) + if not isinstance(participant, ParticipantInDB): + return HTMLResponse( + content="Participants with same control code found", + status_code=status.HTTP_400_BAD_REQUEST, + ) + return SelfServiceResult( + control_code=control_code, + message="Current participant points", + points=participant.points, + ) + + def _get_self_service_participant( control_code: str, ci: str, @@ -224,13 +246,13 @@ def _get_self_service_participant( return participant -@router.get("/gifts", response_model=list[SelfServiceGift]) -async def self_service_gifts(): +@router.get("/rewards", response_model=list[SelfServiceGift]) +async def self_service_rewards(): return GiftService.get_all_virtual() -@router.post("/redeem/gift", response_model=SelfServiceRedeem) -async def self_service_redeem_gift( +@router.post("/redeem/reward", response_model=SelfServiceRedeem) +async def self_service_redeem_reward( redeem_request: SelfServiceRedeemRequest, background_tasks: BackgroundTasks, ): @@ -241,7 +263,7 @@ async def self_service_redeem_gift( if isinstance(participant, HTMLResponse): return participant try: - redeem, product = redeem_gift( + redeem, product = redeem_reward( participant, gift_id, "self_service", @@ -261,15 +283,28 @@ async def self_service_redeem_gift( ) return SelfServiceRedeem( control_code=control_code, - message="Gift redeemed", + message="Reward redeemed", product_name=product.name, discount_code=redeem.discount_code, download_url=redeem.download_url, ) -@router.post("/redeem/meal", response_model=SelfServiceRedeem) -async def self_service_redeem_meal( +@router.get("/redeem/gift", response_model=SelfServiceResult) +async def self_service_check_redeem_gift(): + if ProductService.get_virtual_meal() is None: + return HTMLResponse( + content="Virtual Gift not available or out of stock", + status_code=status.HTTP_400_BAD_REQUEST, + ) + return SelfServiceResult( + control_code="", + message="Virtual Gift available", + ) + + +@router.post("/redeem/gift", response_model=SelfServiceRedeem) +async def self_service_redeem_gift( redeem_request: SelfServiceRedeemRequest, background_tasks: BackgroundTasks, ): @@ -279,7 +314,7 @@ async def self_service_redeem_meal( if isinstance(participant, HTMLResponse): return participant try: - redeem, product = redeem_virtual_meal(participant, "self_service") + redeem, product = redeem_virtual_gift(participant, "self_service") except RedemptionError as error: return HTMLResponse( content=error.message, @@ -294,7 +329,7 @@ async def self_service_redeem_meal( ) return SelfServiceRedeem( control_code=control_code, - message="Meal redeemed", + message="Gift redeemed", product_name=product.name, discount_code=redeem.discount_code, download_url=redeem.download_url, diff --git a/app/services/redemption.py b/app/services/redemption.py index 1641979..9660ecc 100644 --- a/app/services/redemption.py +++ b/app/services/redemption.py @@ -47,7 +47,7 @@ def _release(product: ProductInDB, discount_code: str | None) -> None: ProductService.release_stock(product.id) -def redeem_gift( +def redeem_reward( participant: ParticipantInDB, gift_id: PyObjectId, user_control: str, @@ -55,12 +55,12 @@ def redeem_gift( ) -> tuple[RedeemGiftInDB, ProductInDB]: gift = GiftService.get(gift_id) if gift is None: - raise RedemptionError("Gift not found", status.HTTP_404_NOT_FOUND) + raise RedemptionError("Reward not found", status.HTTP_404_NOT_FOUND) if not gift.multiple and RedeemGiftService.get_redeem_gift_by_participant( participant.control_code, gift_id, ): - raise RedemptionError("Gift already redeemed") + raise RedemptionError("Reward already redeemed") if participant.points < gift.points: raise RedemptionError( f"Not enough points, participant has {participant.points} points", @@ -69,7 +69,7 @@ def redeem_gift( if product is None: raise RedemptionError("Product not found", status.HTTP_404_NOT_FOUND) if virtual and product.kind == "physical": - raise RedemptionError("Gift not available for virtual redemption") + raise RedemptionError("Reward not available for virtual redemption") discount_code = _deliver(product, participant.control_code) redeem = RedeemGiftInDB( @@ -87,17 +87,17 @@ def redeem_gift( inserted = RedeemGiftService.insert(redeem) except DuplicateKeyError: _release(product, discount_code) - raise RedemptionError("Gift already redeemed") + raise RedemptionError("Reward already redeemed") ParticipantService.subtract_points(participant.id, gift.points) return inserted, product -def redeem_virtual_meal( +def redeem_virtual_gift( participant: ParticipantInDB, user_control: str, ) -> tuple[RedeemMealInDB, ProductInDB]: if RedeemMealService.get_by_participant(participant.control_code): - raise RedemptionError("Participant already redeemed meal") + raise RedemptionError("Participant already redeemed gift") product = ProductService.get_virtual_meal() if product is None: raise RedemptionError("Virtual meal not available", status.HTTP_404_NOT_FOUND) @@ -116,7 +116,7 @@ def redeem_virtual_meal( return RedeemMealService.insert(redeem), product except DuplicateKeyError: _release(product, discount_code) - raise RedemptionError("Participant already redeemed meal") + raise RedemptionError("Participant already redeemed gift") def send_redeem_email( From be809cdf9584a0214da1591e7f4fda981024e77b Mon Sep 17 00:00:00 2001 From: Jonathan Villanueva Date: Tue, 6 Oct 2026 00:11:44 +0000 Subject: [PATCH 3/7] feat: add SMTP configuration to production environment --- .env.production | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.env.production b/.env.production index bac414d..5381aad 100644 --- a/.env.production +++ b/.env.production @@ -9,3 +9,9 @@ APP_USERS_API_TOKEN=token TRAEFIK_ROUTE=service_name TRAEFIK_APP_DOMAIN=`dom1.example.com`,`dom2.example.com` TRAEFIK_APP_PORT=8000 +APP_SMTP_HOST=smtp.example.com +APP_SMTP_PORT=587 +APP_SMTP_USER=user +APP_SMTP_PASSWORD=pass +APP_SMTP_FROM=from@example.com +APP_SMTP_STARTTLS=true From b603477f922e9cc76c06e916b3f923f88ebac41f Mon Sep 17 00:00:00 2001 From: Jonathan Villanueva Date: Thu, 15 Oct 2026 22:13:33 +0000 Subject: [PATCH 4/7] feat: add validation for control code and CI in self-service endpoints --- app/routers/points.py | 6 ++++ app/routers/self_service.py | 52 ++++++++++++++++++++++++----- app/utils/control_code_generator.py | 4 +-- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/app/routers/points.py b/app/routers/points.py index 2bd472e..3339f04 100644 --- a/app/routers/points.py +++ b/app/routers/points.py @@ -133,6 +133,12 @@ async def redeem_point_participation( presentation_id: PyObjectId, current_user: Annotated[UserInDB, Depends(get_current_active_user)], ): + participant_control_code = participant_control_code.strip() + if not participant_control_code: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="Control code is required", + ) if Roles.CHECKER not in current_user.roles: return HTMLResponse( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/app/routers/self_service.py b/app/routers/self_service.py index dd4db86..2384e28 100644 --- a/app/routers/self_service.py +++ b/app/routers/self_service.py @@ -39,9 +39,14 @@ @router.post("/registration", response_model=SelfServiceParticipant) async def self_service_registration(register: SelfServiceRegisterRequest): - ci = register.ci + ci = register.ci.strip() birthday = register.birthday birthday_datetime = datetime(birthday.year, birthday.month, birthday.day) + if not ci: + return HTMLResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content="CI is required", + ) find = ParticipantService.get_by_ci_and_birthday(ci=ci, birthday=birthday_datetime) if find is None: return HTMLResponse( @@ -72,7 +77,7 @@ async def self_service_registration(register: SelfServiceRegisterRequest): @router.get("/check/{code}", response_model=SelfServiceResult) async def self_service_participation(code: str): - + code = code.strip() find = PresentationCheckpointLinkService.get_by_code(code) if find is None: return HTMLResponse( @@ -106,7 +111,13 @@ async def self_service_participation_register( code: str, checks: SelfServiceCheckRequest, ): - control_code = checks.control_code + control_code = checks.control_code.strip() + code = code.strip() + if not control_code: + return HTMLResponse( + "Control code is required", + status_code=status.HTTP_400_BAD_REQUEST, + ) find = PresentationCheckpointLinkService.get_by_code(code) if find is None: return HTMLResponse( @@ -213,7 +224,12 @@ async def self_service_participation_register( @router.post("/points", response_model=SelfServiceResult) async def self_service_points(points_request: SelfServiceCheckRequest): - control_code = points_request.control_code + control_code = points_request.control_code.strip() + if not control_code: + return HTMLResponse( + content="Control code is required", + status_code=status.HTTP_400_BAD_REQUEST, + ) participant = ParticipantService.get_by_control_code(control_code) if participant is None: return HTMLResponse( @@ -256,9 +272,19 @@ async def self_service_redeem_reward( redeem_request: SelfServiceRedeemRequest, background_tasks: BackgroundTasks, ): - control_code = redeem_request.control_code - ci = redeem_request.ci + control_code = redeem_request.control_code.strip() + ci = redeem_request.ci.strip() gift_id = redeem_request.gift + if not control_code: + return HTMLResponse( + content="Control code is required", + status_code=status.HTTP_400_BAD_REQUEST, + ) + if not ci: + return HTMLResponse( + content="CI is required", + status_code=status.HTTP_400_BAD_REQUEST, + ) participant = _get_self_service_participant(control_code, ci) if isinstance(participant, HTMLResponse): return participant @@ -308,8 +334,18 @@ async def self_service_redeem_gift( redeem_request: SelfServiceRedeemRequest, background_tasks: BackgroundTasks, ): - control_code = redeem_request.control_code - ci = redeem_request.ci + control_code = redeem_request.control_code.strip() + ci = redeem_request.ci.strip() + if not control_code: + return HTMLResponse( + content="Control code is required", + status_code=status.HTTP_400_BAD_REQUEST, + ) + if not ci: + return HTMLResponse( + content="CI is required", + status_code=status.HTTP_400_BAD_REQUEST, + ) participant = _get_self_service_participant(control_code, ci) if isinstance(participant, HTMLResponse): return participant diff --git a/app/utils/control_code_generator.py b/app/utils/control_code_generator.py index a029593..ac1d087 100644 --- a/app/utils/control_code_generator.py +++ b/app/utils/control_code_generator.py @@ -152,11 +152,11 @@ def generate_name() -> str: """ Genera un identificador al estilo Docker: - adjetivo-nombre-digito + adjetivo-nombre Ejemplo: - valiente-illimani-7 + valiente-illimani """ left = random.choice(LEFT) From b0b54b3553bb61f9d07a373c7ee59de09242d779 Mon Sep 17 00:00:00 2001 From: Jonathan Villanueva Date: Thu, 15 Oct 2026 22:41:54 +0000 Subject: [PATCH 5/7] feat: add presentation field to Control model and implement latest check-in retrieval in ControlService --- app/models/control.py | 1 + app/routers/self_service.py | 35 +++++++++++++++++++++++++++++++---- app/services/control.py | 19 +++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/app/models/control.py b/app/models/control.py index 07a05cc..fd9a785 100644 --- a/app/models/control.py +++ b/app/models/control.py @@ -12,6 +12,7 @@ class Control(Base): participant_control_code: str = "" location: PyObjectId = None location_name: str = "" + presentation: Optional[PyObjectId] = None control: Literal["in", "out"] date: datetime = None user_control: str = "" diff --git a/app/routers/self_service.py b/app/routers/self_service.py index 2384e28..ce26597 100644 --- a/app/routers/self_service.py +++ b/app/routers/self_service.py @@ -173,17 +173,43 @@ async def self_service_participation_register( user_control = "self_service" in_and_outs = ControlService.count_in_and_outs(control_code, presentation.location) + if in_and_outs > 0: + current_check_in = ControlService.get_latest_check_in( + control_code, + presentation.location, + ) + if ( + current_check_in is not None + and current_check_in.presentation != presentation.id + ): + check_out = ControlInDB( + participant_control_code=control_code, + location=presentation.location, + location_name=location.name, + presentation=current_check_in.presentation, + control="out", + date=datetime.now(), + user_control=user_control, + ) + check_out.username_insert = user_control + ControlService.insert(check_out) + in_and_outs -= 1 + if in_and_outs <= 0: - # Virtual attendance: auto check-in, unless checked in elsewhere if ControlService.count_in_and_outs_global(control_code) > 0: - return SelfServiceResult( - control_code=control_code, - message="Current check in a location", + return HTMLResponse( + content=( + "You are still checked in at another location. " + "Register your check-out there before joining this presentation. " + "This checkpoint was not registered and no points were awarded." + ), + status_code=status.HTTP_400_BAD_REQUEST, ) check_in = ControlInDB( participant_control_code=control_code, location=presentation.location, location_name=location.name, + presentation=presentation.id, control="in", date=datetime.now(), user_control=user_control, @@ -208,6 +234,7 @@ async def self_service_participation_register( participant_control_code=control_code, location=presentation.location, location_name=location.name, + presentation=presentation.id, control="out", date=datetime.now(), user_control=user_control, diff --git a/app/services/control.py b/app/services/control.py index 456cc54..f00e634 100644 --- a/app/services/control.py +++ b/app/services/control.py @@ -30,6 +30,25 @@ def get_all_by_participant(cls, participant_control_code: str) -> list[ControlIn ) return [ControlInDB(**item) for item in items] + @classmethod + def get_latest_check_in( + cls, + participant_control_code: str, + location: PyObjectId, + ) -> ControlInDB | None: + item = cls.TABLE.find_one( + { + "participant_control_code": participant_control_code, + "location": location, + "control": "in", + "disabled": False, + }, + sort=[("date_insert", -1), ("_id", -1)], + ) + if item is not None: + return ControlInDB(**item) + return None + @classmethod def count_checks( cls, From e342e42948f78453ee558cf8e5d0a5293c9338af Mon Sep 17 00:00:00 2001 From: Jonathan Villanueva Date: Sat, 17 Oct 2026 03:40:09 +0000 Subject: [PATCH 6/7] feat: implement CI/CD workflows for development and production environments --- .github/workflows/dev-0-ci-cd.yaml | 38 +++++++++++++++ .github/workflows/dev-1-ci.yaml | 71 ++++++++++++++++++++++++++++ .github/workflows/dev-2-cd.yaml | 72 ++++++++++++++++++++++++++++ .github/workflows/docker-image.yml | 36 -------------- .github/workflows/prod-0-ci-cd.yaml | 40 ++++++++++++++++ .github/workflows/prod-1-ci.yaml | 73 +++++++++++++++++++++++++++++ .github/workflows/prod-2-cd.yaml | 72 ++++++++++++++++++++++++++++ .github/workflows/z-lint.yaml | 25 ++++++++++ 8 files changed, 391 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/dev-0-ci-cd.yaml create mode 100644 .github/workflows/dev-1-ci.yaml create mode 100644 .github/workflows/dev-2-cd.yaml delete mode 100644 .github/workflows/docker-image.yml create mode 100644 .github/workflows/prod-0-ci-cd.yaml create mode 100644 .github/workflows/prod-1-ci.yaml create mode 100644 .github/workflows/prod-2-cd.yaml create mode 100644 .github/workflows/z-lint.yaml diff --git a/.github/workflows/dev-0-ci-cd.yaml b/.github/workflows/dev-0-ci-cd.yaml new file mode 100644 index 0000000..6edcc2b --- /dev/null +++ b/.github/workflows/dev-0-ci-cd.yaml @@ -0,0 +1,38 @@ +name: Dev CI/CD +on: + pull_request: + branches: + - dev + push: + branches: + - dev +jobs: + + lint: + uses: ./.github/workflows/z-lint.yaml + + build: + if: github.ref == 'refs/heads/dev' + uses: ./.github/workflows/dev-1-ci.yaml + needs: lint + with: + environment: dev + image_repository: ${{ vars.DOCKER_IMAGE }} + push_image: ${{ github.event_name == 'push' }} + secrets: + gcp_sa_key: ${{ secrets.GCP_SA_KEY }} + + deploy: + if: github.ref == 'refs/heads/dev' + needs: build + uses: ./.github/workflows/dev-2-cd.yaml + with: + environment: dev + image_repository: ${{ vars.DOCKER_IMAGE }} + image_tag: ${{ needs.build.outputs.image_tag }} + deployment_repository: ${{ vars.ARGOCD_REPOSITORY }} + deployment_ref: ${{ vars.ARGOCD_REF || 'main' }} + image_repository_path: ${{ vars.ARGOCD_IMAGE_REPOSITORY_PATH || '.image.repository' }} + image_tag_path: ${{ vars.ARGOCD_IMAGE_TAG_PATH || '.image.tag' }} + secrets: + token: ${{ secrets.ARGOCD_GH_TOKEN }} diff --git a/.github/workflows/dev-1-ci.yaml b/.github/workflows/dev-1-ci.yaml new file mode 100644 index 0000000..ea58bf6 --- /dev/null +++ b/.github/workflows/dev-1-ci.yaml @@ -0,0 +1,71 @@ +name: Dev CI - Build and Push Image + +on: + workflow_call: + inputs: + environment: + description: GitHub Environment used for this build + required: true + type: string + image_repository: + description: GCR image repository for the dev environment + required: true + type: string + push_image: + description: Whether to push the image to GCR + required: false + default: false + type: boolean + secrets: + gcp_sa_key: + required: false + outputs: + image_tag: + description: Tag of the built dev image + value: ${{ jobs.build.outputs.image_tag }} + +jobs: + build: + name: Build dev image + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + outputs: + image_tag: ${{ steps.image.outputs.tag }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate image repository + env: + IMAGE_REPOSITORY: ${{ inputs.image_repository }} + run: test -n "$IMAGE_REPOSITORY" + + - name: Set image tag + id: image + run: echo "tag=dev-${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GCR + if: inputs.push_image + uses: docker/login-action@v3 + with: + registry: gcr.io + username: _json_key + password: ${{ secrets.gcp_sa_key }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ inputs.push_image }} + tags: | + ${{ inputs.image_repository }}:${{ steps.image.outputs.tag }} + ${{ inputs.image_repository }}:dev-latest + cache-from: type=gha,scope=dev + cache-to: type=gha,mode=max,scope=dev diff --git a/.github/workflows/dev-2-cd.yaml b/.github/workflows/dev-2-cd.yaml new file mode 100644 index 0000000..5359f37 --- /dev/null +++ b/.github/workflows/dev-2-cd.yaml @@ -0,0 +1,72 @@ +name: Dev CD - Update Image on Deployment Repository + +on: + workflow_call: + inputs: + environment: + description: GitHub Environment used for this deployment + required: true + type: string + image_repository: + required: true + type: string + image_tag: + required: true + type: string + deployment_repository: + required: true + type: string + deployment_ref: + required: false + default: main + type: string + image_repository_path: + required: false + default: .image.repository + type: string + image_tag_path: + required: false + default: .image.tag + type: string + secrets: + token: + required: true + +jobs: + update-image: + name: Update dev image in GitOps repository + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + steps: + - name: Checkout deployment repository + uses: actions/checkout@v4 + with: + repository: ${{ inputs.deployment_repository }} + ref: ${{ inputs.deployment_ref }} + token: ${{ secrets.token }} + path: deployment-config + + - name: Set up yq + uses: shiipou/setup-yq-action@v2.2.0 + + - name: Update image repository and tag + env: + VALUES_FILE: ${{ vars.ARGOCD_VALUES_FILE }} + IMAGE_REPOSITORY: ${{ inputs.image_repository }} + IMAGE_TAG: ${{ inputs.image_tag }} + IMAGE_REPOSITORY_PATH: ${{ inputs.image_repository_path }} + IMAGE_TAG_PATH: ${{ inputs.image_tag_path }} + run: | + test -n "$VALUES_FILE" + test -f "deployment-config/$VALUES_FILE" + yq -i 'eval(strenv(IMAGE_REPOSITORY_PATH)) = strenv(IMAGE_REPOSITORY) | eval(strenv(IMAGE_TAG_PATH)) = strenv(IMAGE_TAG)' "deployment-config/$VALUES_FILE" + + git -C deployment-config config user.email "github-actions[bot]@users.noreply.github.com" + git -C deployment-config config user.name "github-actions[bot]" + git -C deployment-config add "$VALUES_FILE" + if git -C deployment-config diff --cached --quiet; then + echo "Image values are already up to date." + else + git -C deployment-config commit -m "Deploy dev image $IMAGE_TAG" + git -C deployment-config push + fi diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml deleted file mode 100644 index a58b84b..0000000 --- a/.github/workflows/docker-image.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Docker Image CI - -on: - push: - branches: [master] - pull_request: - branches: [master] - -env: - IMAGE_TAG: ${{ github.sha }} - DOCKER_IMAGE: ${{ vars.DOCKER_IMAGE }} -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Login to GCR - uses: docker/login-action@v2 - with: - registry: gcr.io - username: _json_key - password: ${{ secrets.GCP_SA_KEY }} - - - name: Build and push - uses: docker/build-push-action@v3 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{env.DOCKER_IMAGE}}:${{env.IMAGE_TAG}},${{env.DOCKER_IMAGE}}:latest diff --git a/.github/workflows/prod-0-ci-cd.yaml b/.github/workflows/prod-0-ci-cd.yaml new file mode 100644 index 0000000..7956554 --- /dev/null +++ b/.github/workflows/prod-0-ci-cd.yaml @@ -0,0 +1,40 @@ +name: Prod CI/CD + +on: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + + lint: + uses: ./.github/workflows/z-lint.yaml + + build: + if: github.ref == 'refs/heads/main' + uses: ./.github/workflows/prod-1-ci.yaml + needs: lint + with: + environment: prod + image_repository: ${{ vars.DOCKER_IMAGE }} + push_image: ${{ github.event_name == 'push' }} + secrets: + gcp_sa_key: ${{ secrets.GCP_SA_KEY }} + + deploy: + if: github.ref == 'refs/heads/main' + uses: ./.github/workflows/prod-2-cd.yaml + needs: build + with: + environment: prod + image_repository: ${{ vars.DOCKER_IMAGE }} + image_tag: ${{ needs.build.outputs.image_tag }} + deployment_repository: ${{ vars.ARGOCD_REPOSITORY }} + deployment_ref: ${{ vars.ARGOCD_REF || 'main' }} + image_repository_path: ${{ vars.ARGOCD_IMAGE_REPOSITORY_PATH || '.image.repository' }} + image_tag_path: ${{ vars.ARGOCD_IMAGE_TAG_PATH || '.image.tag' }} + secrets: + token: ${{ secrets.ARGOCD_GH_TOKEN }} diff --git a/.github/workflows/prod-1-ci.yaml b/.github/workflows/prod-1-ci.yaml new file mode 100644 index 0000000..5bfb30e --- /dev/null +++ b/.github/workflows/prod-1-ci.yaml @@ -0,0 +1,73 @@ +name: Prod CI - Build and Push Image + +on: + workflow_call: + inputs: + environment: + description: GitHub Environment used for this build + required: true + type: string + image_repository: + description: GCR image repository + required: true + type: string + push_image: + description: Whether to push the image to GCR + required: false + default: false + type: boolean + secrets: + gcp_sa_key: + required: false + outputs: + image_tag: + description: Tag of the built production image + value: ${{ jobs.build.outputs.image_tag }} + +jobs: + build: + name: Build production image + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + outputs: + image_tag: ${{ steps.image.outputs.tag }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate image repository + env: + IMAGE_REPOSITORY: ${{ inputs.image_repository }} + run: test -n "$IMAGE_REPOSITORY" + + - name: Set image tag + id: image + run: echo "tag=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GCR + if: inputs.push_image + uses: docker/login-action@v3 + with: + registry: gcr.io + username: _json_key + password: ${{ secrets.gcp_sa_key }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ inputs.push_image }} + tags: | + ${{ inputs.image_repository }}:${{ steps.image.outputs.tag }} + ${{ inputs.image_repository }}:latest + build-args: | + NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }} + cache-from: type=gha,scope=prod + cache-to: type=gha,mode=max,scope=prod diff --git a/.github/workflows/prod-2-cd.yaml b/.github/workflows/prod-2-cd.yaml new file mode 100644 index 0000000..86d4a37 --- /dev/null +++ b/.github/workflows/prod-2-cd.yaml @@ -0,0 +1,72 @@ +name: Prod CD - Update Image on Deployment Repository + +on: + workflow_call: + inputs: + environment: + description: GitHub Environment used for this deployment + required: true + type: string + image_repository: + required: true + type: string + image_tag: + required: true + type: string + deployment_repository: + required: true + type: string + deployment_ref: + required: false + default: main + type: string + image_repository_path: + required: false + default: .image.repository + type: string + image_tag_path: + required: false + default: .image.tag + type: string + secrets: + token: + required: true + +jobs: + update-image: + name: Update production image in GitOps repository + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + steps: + - name: Checkout deployment repository + uses: actions/checkout@v4 + with: + repository: ${{ inputs.deployment_repository }} + ref: ${{ inputs.deployment_ref }} + token: ${{ secrets.token }} + path: deployment-config + + - name: Set up yq + uses: shiipou/setup-yq-action@v2.2.0 + + - name: Update image repository and tag + env: + VALUES_FILE: ${{ vars.ARGOCD_VALUES_FILE }} + IMAGE_REPOSITORY: ${{ inputs.image_repository }} + IMAGE_TAG: ${{ inputs.image_tag }} + IMAGE_REPOSITORY_PATH: ${{ inputs.image_repository_path }} + IMAGE_TAG_PATH: ${{ inputs.image_tag_path }} + run: | + test -n "$VALUES_FILE" + test -f "deployment-config/$VALUES_FILE" + yq -i 'eval(strenv(IMAGE_REPOSITORY_PATH)) = strenv(IMAGE_REPOSITORY) | eval(strenv(IMAGE_TAG_PATH)) = strenv(IMAGE_TAG)' "deployment-config/$VALUES_FILE" + + git -C deployment-config config user.email "github-actions[bot]@users.noreply.github.com" + git -C deployment-config config user.name "github-actions[bot]" + git -C deployment-config add "$VALUES_FILE" + if git -C deployment-config diff --cached --quiet; then + echo "Image values are already up to date." + else + git -C deployment-config commit -m "Deploy production image $IMAGE_TAG" + git -C deployment-config push + fi diff --git a/.github/workflows/z-lint.yaml b/.github/workflows/z-lint.yaml new file mode 100644 index 0000000..2221218 --- /dev/null +++ b/.github/workflows/z-lint.yaml @@ -0,0 +1,25 @@ +name: Lint Python Code + +on: + workflow_call: + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.13" + + - name: Install Ruff + run: pip install ruff + + - name: Run Ruff Linter + run: ruff check . --exit-zero --output-format=github + + - name: Run Ruff (blocking rules) + run: ruff check . --select=I,F401,F541,E713,E714,E721,E,F,COM812,TD,B006,PLC,ERA,INT,LOG,PT,ISC,G,EXE,TC,PIE,T,SIM From 28a9f71e6a6a42a44894ed3ebb046b7a5dff3324 Mon Sep 17 00:00:00 2001 From: Jonathan Villanueva Date: Sun, 18 Oct 2026 16:37:41 +0000 Subject: [PATCH 7/7] feat: update CI/CD workflows to use Docker Hub credentials instead of GCP --- .github/workflows/dev-0-ci-cd.yaml | 3 ++- .github/workflows/dev-1-ci.yaml | 19 ++++++++++--------- .github/workflows/prod-0-ci-cd.yaml | 5 +++-- .github/workflows/prod-1-ci.yaml | 19 ++++++++++--------- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/.github/workflows/dev-0-ci-cd.yaml b/.github/workflows/dev-0-ci-cd.yaml index 6edcc2b..cb24b59 100644 --- a/.github/workflows/dev-0-ci-cd.yaml +++ b/.github/workflows/dev-0-ci-cd.yaml @@ -20,7 +20,8 @@ jobs: image_repository: ${{ vars.DOCKER_IMAGE }} push_image: ${{ github.event_name == 'push' }} secrets: - gcp_sa_key: ${{ secrets.GCP_SA_KEY }} + docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }} + docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} deploy: if: github.ref == 'refs/heads/dev' diff --git a/.github/workflows/dev-1-ci.yaml b/.github/workflows/dev-1-ci.yaml index ea58bf6..316e74a 100644 --- a/.github/workflows/dev-1-ci.yaml +++ b/.github/workflows/dev-1-ci.yaml @@ -8,16 +8,18 @@ on: required: true type: string image_repository: - description: GCR image repository for the dev environment + description: Docker Hub image repository for the dev environment required: true type: string push_image: - description: Whether to push the image to GCR + description: Whether to push the image to Docker Hub required: false default: false type: boolean secrets: - gcp_sa_key: + docker_hub_username: + required: false + docker_hub_access_token: required: false outputs: image_tag: @@ -50,13 +52,12 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to GCR + - name: Login to Docker Hub if: inputs.push_image - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: - registry: gcr.io - username: _json_key - password: ${{ secrets.gcp_sa_key }} + username: ${{ secrets.docker_hub_username }} + password: ${{ secrets.docker_hub_access_token }} - name: Build and push uses: docker/build-push-action@v6 @@ -68,4 +69,4 @@ jobs: ${{ inputs.image_repository }}:${{ steps.image.outputs.tag }} ${{ inputs.image_repository }}:dev-latest cache-from: type=gha,scope=dev - cache-to: type=gha,mode=max,scope=dev + cache-to: type=gha,mode=max,scope=dev diff --git a/.github/workflows/prod-0-ci-cd.yaml b/.github/workflows/prod-0-ci-cd.yaml index 7956554..91596b7 100644 --- a/.github/workflows/prod-0-ci-cd.yaml +++ b/.github/workflows/prod-0-ci-cd.yaml @@ -22,7 +22,8 @@ jobs: image_repository: ${{ vars.DOCKER_IMAGE }} push_image: ${{ github.event_name == 'push' }} secrets: - gcp_sa_key: ${{ secrets.GCP_SA_KEY }} + docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }} + docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} deploy: if: github.ref == 'refs/heads/main' @@ -37,4 +38,4 @@ jobs: image_repository_path: ${{ vars.ARGOCD_IMAGE_REPOSITORY_PATH || '.image.repository' }} image_tag_path: ${{ vars.ARGOCD_IMAGE_TAG_PATH || '.image.tag' }} secrets: - token: ${{ secrets.ARGOCD_GH_TOKEN }} + token: ${{ secrets.ARGOCD_GH_TOKEN }} diff --git a/.github/workflows/prod-1-ci.yaml b/.github/workflows/prod-1-ci.yaml index 5bfb30e..f9f6244 100644 --- a/.github/workflows/prod-1-ci.yaml +++ b/.github/workflows/prod-1-ci.yaml @@ -8,16 +8,18 @@ on: required: true type: string image_repository: - description: GCR image repository + description: Docker Hub image repository required: true type: string push_image: - description: Whether to push the image to GCR + description: Whether to push the image to Docker Hub required: false default: false type: boolean secrets: - gcp_sa_key: + docker_hub_username: + required: false + docker_hub_access_token: required: false outputs: image_tag: @@ -50,13 +52,12 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to GCR + - name: Login to Docker Hub if: inputs.push_image - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: - registry: gcr.io - username: _json_key - password: ${{ secrets.gcp_sa_key }} + username: ${{ secrets.docker_hub_username }} + password: ${{ secrets.docker_hub_access_token }} - name: Build and push uses: docker/build-push-action@v6 @@ -70,4 +71,4 @@ jobs: build-args: | NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }} cache-from: type=gha,scope=prod - cache-to: type=gha,mode=max,scope=prod + cache-to: type=gha,mode=max,scope=prod