Spaces:
Paused
Paused
Upload 86 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .coverage +0 -0
- .dockerignore +43 -0
- .env +14 -0
- Dockerfile +58 -0
- alembic.ini +77 -0
- alembic/README +1 -0
- alembic/__pycache__/env.cpython-312.pyc +0 -0
- alembic/env.py +63 -0
- alembic/script.py.mako +26 -0
- app/__init__.py +0 -0
- app/__pycache__/__init__.cpython-312.pyc +0 -0
- app/__pycache__/main.cpython-312.pyc +0 -0
- app/api/__init__.py +0 -0
- app/api/__pycache__/__init__.cpython-312.pyc +0 -0
- app/api/__pycache__/analytics.cpython-312.pyc +0 -0
- app/api/__pycache__/auth.cpython-312.pyc +0 -0
- app/api/__pycache__/calendar.cpython-312.pyc +0 -0
- app/api/__pycache__/files.cpython-312.pyc +0 -0
- app/api/__pycache__/maintenance.cpython-312.pyc +0 -0
- app/api/__pycache__/notifications.cpython-312.pyc +0 -0
- app/api/__pycache__/orders.cpython-312.pyc +0 -0
- app/api/__pycache__/products.cpython-312.pyc +0 -0
- app/api/__pycache__/scheduler.cpython-312.pyc +0 -0
- app/api/__pycache__/users.cpython-312.pyc +0 -0
- app/api/analytics.py +232 -0
- app/api/auth.py +70 -0
- app/api/branches.py +89 -0
- app/api/calendar.py +156 -0
- app/api/files.py +53 -0
- app/api/maintenance.py +133 -0
- app/api/notifications.py +86 -0
- app/api/orders.py +186 -0
- app/api/products.py +131 -0
- app/api/scheduler.py +203 -0
- app/api/users.py +127 -0
- app/core/__init__.py +0 -0
- app/core/__pycache__/__init__.cpython-312.pyc +0 -0
- app/core/__pycache__/config.cpython-312.pyc +0 -0
- app/core/__pycache__/dependencies.cpython-312.pyc +0 -0
- app/core/__pycache__/security.cpython-312.pyc +0 -0
- app/core/config.py +36 -0
- app/core/dependencies.py +52 -0
- app/core/security.py +23 -0
- app/db/__init__.py +0 -0
- app/db/__pycache__/__init__.cpython-312.pyc +0 -0
- app/db/__pycache__/database.cpython-312.pyc +0 -0
- app/db/__pycache__/models.cpython-312.pyc +0 -0
- app/db/__pycache__/schemas.cpython-312.pyc +0 -0
- app/db/database.py +55 -0
- app/db/init_db.py +77 -0
.coverage
ADDED
|
Binary file (53.2 kB). View file
|
|
|
.dockerignore
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Git
|
| 2 |
+
.git
|
| 3 |
+
.gitignore
|
| 4 |
+
|
| 5 |
+
# Python
|
| 6 |
+
__pycache__/
|
| 7 |
+
*.py[cod]
|
| 8 |
+
*$py.class
|
| 9 |
+
*.so
|
| 10 |
+
.Python
|
| 11 |
+
env/
|
| 12 |
+
build/
|
| 13 |
+
develop-eggs/
|
| 14 |
+
dist/
|
| 15 |
+
downloads/
|
| 16 |
+
eggs/
|
| 17 |
+
.eggs/
|
| 18 |
+
lib/
|
| 19 |
+
lib64/
|
| 20 |
+
parts/
|
| 21 |
+
sdist/
|
| 22 |
+
var/
|
| 23 |
+
wheels/
|
| 24 |
+
*.egg-info/
|
| 25 |
+
.installed.cfg
|
| 26 |
+
*.egg
|
| 27 |
+
|
| 28 |
+
# Virtual Environment
|
| 29 |
+
venv/
|
| 30 |
+
ENV/
|
| 31 |
+
|
| 32 |
+
# IDE
|
| 33 |
+
.idea/
|
| 34 |
+
.vscode/
|
| 35 |
+
*.swp
|
| 36 |
+
*.swo
|
| 37 |
+
|
| 38 |
+
# Project specific
|
| 39 |
+
logs/
|
| 40 |
+
uploads/
|
| 41 |
+
backups/
|
| 42 |
+
.env
|
| 43 |
+
*.log
|
.env
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
PROJECT_NAME=Admin Dashboard API
|
| 2 |
+
VERSION=1.0.0
|
| 3 |
+
API_V1_STR=/api/v1
|
| 4 |
+
|
| 5 |
+
# Security
|
| 6 |
+
SECRET_KEY=your-secret-key-here-change-in-production
|
| 7 |
+
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
| 8 |
+
ALGORITHM=HS256
|
| 9 |
+
|
| 10 |
+
# Database
|
| 11 |
+
DATABASE_URL=postgresql+asyncpg://postgres:Lovyelias5584.@db.juycnkjuzylnbruwaqmp.supabase.co:5432/postgres
|
| 12 |
+
# Redis Cache
|
| 13 |
+
REDIS_HOST=localhost
|
| 14 |
+
REDIS_PORT=6379
|
Dockerfile
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Python 3.11 slim as base image
|
| 2 |
+
FROM python:3.11-slim as builder
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install system dependencies
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
gcc \
|
| 10 |
+
libpq-dev \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
# Install Python dependencies
|
| 14 |
+
COPY requirements.txt .
|
| 15 |
+
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt
|
| 16 |
+
|
| 17 |
+
# Final stage
|
| 18 |
+
FROM python:3.11-slim
|
| 19 |
+
|
| 20 |
+
# Create non-root user
|
| 21 |
+
RUN useradd -m appuser
|
| 22 |
+
|
| 23 |
+
# Set working directory
|
| 24 |
+
WORKDIR /app
|
| 25 |
+
|
| 26 |
+
# Install system dependencies
|
| 27 |
+
RUN apt-get update && apt-get install -y \
|
| 28 |
+
libpq5 \
|
| 29 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 30 |
+
|
| 31 |
+
# Copy wheels from builder stage
|
| 32 |
+
COPY --from=builder /app/wheels /wheels
|
| 33 |
+
COPY --from=builder /app/requirements.txt .
|
| 34 |
+
|
| 35 |
+
# Install Python packages
|
| 36 |
+
RUN pip install --no-cache /wheels/*
|
| 37 |
+
|
| 38 |
+
# Copy application code
|
| 39 |
+
COPY ./app app/
|
| 40 |
+
COPY ./alembic.ini .
|
| 41 |
+
COPY ./alembic alembic/
|
| 42 |
+
|
| 43 |
+
# Create necessary directories with proper permissions
|
| 44 |
+
RUN mkdir -p /app/logs /app/uploads/images /app/uploads/documents /app/backups && \
|
| 45 |
+
chown -R appuser:appuser /app
|
| 46 |
+
|
| 47 |
+
# Switch to non-root user
|
| 48 |
+
USER appuser
|
| 49 |
+
|
| 50 |
+
# Set environment variables
|
| 51 |
+
ENV PYTHONPATH=/app \
|
| 52 |
+
PYTHONUNBUFFERED=1
|
| 53 |
+
|
| 54 |
+
# Expose port
|
| 55 |
+
EXPOSE 8000
|
| 56 |
+
|
| 57 |
+
# Start the application with uvicorn
|
| 58 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
alembic.ini
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# A generic, single database configuration.
|
| 2 |
+
|
| 3 |
+
[alembic]
|
| 4 |
+
# path to migration scripts
|
| 5 |
+
script_location = alembic
|
| 6 |
+
|
| 7 |
+
# template used to generate migration files
|
| 8 |
+
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
| 9 |
+
|
| 10 |
+
# timezone to use when rendering the date within the migration file
|
| 11 |
+
# as well as the filename.
|
| 12 |
+
timezone = UTC
|
| 13 |
+
|
| 14 |
+
# max length of characters to apply to the "slug" field
|
| 15 |
+
truncate_slug_length = 40
|
| 16 |
+
|
| 17 |
+
# set to 'true' to run the environment during
|
| 18 |
+
# the 'revision' command, regardless of autogenerate
|
| 19 |
+
revision_environment = false
|
| 20 |
+
|
| 21 |
+
# set to 'true' to allow .pyc and .pyo files without
|
| 22 |
+
# a source .py file to be detected as revisions in the
|
| 23 |
+
# versions/ directory
|
| 24 |
+
sourceless = false
|
| 25 |
+
|
| 26 |
+
# version location specification
|
| 27 |
+
version_locations = alembic/versions
|
| 28 |
+
|
| 29 |
+
# version path separator
|
| 30 |
+
version_path_separator = os
|
| 31 |
+
|
| 32 |
+
# the output encoding used when revision files
|
| 33 |
+
# are written from script.py.mako
|
| 34 |
+
output_encoding = utf-8
|
| 35 |
+
|
| 36 |
+
sqlalchemy.url = postgresql+psycopg2://postgres:Lovyelias5584.@db.mqyrkmsdgugdhxiucukb.supabase.co:5432/postgres
|
| 37 |
+
|
| 38 |
+
[post_write_hooks]
|
| 39 |
+
# format using "black"
|
| 40 |
+
hooks = black
|
| 41 |
+
black.type = console_scripts
|
| 42 |
+
black.entrypoint = black
|
| 43 |
+
black.options = -l 79 REVISION_SCRIPT_FILENAME
|
| 44 |
+
|
| 45 |
+
[loggers]
|
| 46 |
+
keys = root,sqlalchemy,alembic
|
| 47 |
+
|
| 48 |
+
[handlers]
|
| 49 |
+
keys = console
|
| 50 |
+
|
| 51 |
+
[formatters]
|
| 52 |
+
keys = generic
|
| 53 |
+
|
| 54 |
+
[logger_root]
|
| 55 |
+
level = WARN
|
| 56 |
+
handlers = console
|
| 57 |
+
qualname =
|
| 58 |
+
|
| 59 |
+
[logger_sqlalchemy]
|
| 60 |
+
level = WARN
|
| 61 |
+
handlers =
|
| 62 |
+
qualname = sqlalchemy.engine
|
| 63 |
+
|
| 64 |
+
[logger_alembic]
|
| 65 |
+
level = INFO
|
| 66 |
+
handlers =
|
| 67 |
+
qualname = alembic
|
| 68 |
+
|
| 69 |
+
[handler_console]
|
| 70 |
+
class = StreamHandler
|
| 71 |
+
args = (sys.stderr,)
|
| 72 |
+
level = NOTSET
|
| 73 |
+
formatter = generic
|
| 74 |
+
|
| 75 |
+
[formatter_generic]
|
| 76 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 77 |
+
datefmt = %H:%M:%S
|
alembic/README
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Generic single-database configuration.
|
alembic/__pycache__/env.cpython-312.pyc
ADDED
|
Binary file (2.93 kB). View file
|
|
|
alembic/env.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from logging.config import fileConfig
|
| 2 |
+
from sqlalchemy import engine_from_config
|
| 3 |
+
from sqlalchemy import pool
|
| 4 |
+
from alembic import context
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
# Add the parent directory to the Python path
|
| 10 |
+
parent_dir = str(Path(__file__).resolve().parents[1])
|
| 11 |
+
sys.path.append(parent_dir)
|
| 12 |
+
|
| 13 |
+
from app.core.config import settings
|
| 14 |
+
from app.db.models import Base
|
| 15 |
+
|
| 16 |
+
config = context.config
|
| 17 |
+
|
| 18 |
+
if config.config_file_name is not None:
|
| 19 |
+
fileConfig(config.config_file_name)
|
| 20 |
+
|
| 21 |
+
def get_url():
|
| 22 |
+
return str(settings.DATABASE_URL).replace("+asyncpg", "+psycopg2")
|
| 23 |
+
|
| 24 |
+
config.set_main_option("sqlalchemy.url", get_url())
|
| 25 |
+
|
| 26 |
+
target_metadata = Base.metadata
|
| 27 |
+
|
| 28 |
+
def run_migrations_offline() -> None:
|
| 29 |
+
"""Run migrations in 'offline' mode."""
|
| 30 |
+
url = get_url()
|
| 31 |
+
context.configure(
|
| 32 |
+
url=url,
|
| 33 |
+
target_metadata=target_metadata,
|
| 34 |
+
literal_binds=True,
|
| 35 |
+
dialect_opts={"paramstyle": "named"},
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
with context.begin_transaction():
|
| 39 |
+
context.run_migrations()
|
| 40 |
+
|
| 41 |
+
def run_migrations_online() -> None:
|
| 42 |
+
"""Run migrations in 'online' mode."""
|
| 43 |
+
configuration = config.get_section(config.config_ini_section)
|
| 44 |
+
configuration["sqlalchemy.url"] = get_url()
|
| 45 |
+
connectable = engine_from_config(
|
| 46 |
+
configuration,
|
| 47 |
+
prefix="sqlalchemy.",
|
| 48 |
+
poolclass=pool.NullPool,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
with connectable.connect() as connection:
|
| 52 |
+
context.configure(
|
| 53 |
+
connection=connection,
|
| 54 |
+
target_metadata=target_metadata
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
with context.begin_transaction():
|
| 58 |
+
context.run_migrations()
|
| 59 |
+
|
| 60 |
+
if context.is_offline_mode():
|
| 61 |
+
run_migrations_offline()
|
| 62 |
+
else:
|
| 63 |
+
run_migrations_online()
|
alembic/script.py.mako
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""${message}
|
| 2 |
+
|
| 3 |
+
Revision ID: ${up_revision}
|
| 4 |
+
Revises: ${down_revision | comma,n}
|
| 5 |
+
Create Date: ${create_date}
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
${imports if imports else ""}
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = ${repr(up_revision)}
|
| 16 |
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
${upgrades if upgrades else "pass"}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def downgrade() -> None:
|
| 26 |
+
${downgrades if downgrades else "pass"}
|
app/__init__.py
ADDED
|
File without changes
|
app/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (168 Bytes). View file
|
|
|
app/__pycache__/main.cpython-312.pyc
ADDED
|
Binary file (6 kB). View file
|
|
|
app/api/__init__.py
ADDED
|
File without changes
|
app/api/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (172 Bytes). View file
|
|
|
app/api/__pycache__/analytics.cpython-312.pyc
ADDED
|
Binary file (11.4 kB). View file
|
|
|
app/api/__pycache__/auth.cpython-312.pyc
ADDED
|
Binary file (3.42 kB). View file
|
|
|
app/api/__pycache__/calendar.cpython-312.pyc
ADDED
|
Binary file (7.59 kB). View file
|
|
|
app/api/__pycache__/files.cpython-312.pyc
ADDED
|
Binary file (2.83 kB). View file
|
|
|
app/api/__pycache__/maintenance.cpython-312.pyc
ADDED
|
Binary file (6.57 kB). View file
|
|
|
app/api/__pycache__/notifications.cpython-312.pyc
ADDED
|
Binary file (5.22 kB). View file
|
|
|
app/api/__pycache__/orders.cpython-312.pyc
ADDED
|
Binary file (8.61 kB). View file
|
|
|
app/api/__pycache__/products.cpython-312.pyc
ADDED
|
Binary file (6.76 kB). View file
|
|
|
app/api/__pycache__/scheduler.cpython-312.pyc
ADDED
|
Binary file (9.57 kB). View file
|
|
|
app/api/__pycache__/users.cpython-312.pyc
ADDED
|
Binary file (6.79 kB). View file
|
|
|
app/api/analytics.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, Query, HTTPException
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy import select, func, cast, Date, and_
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from typing import Dict, Any, Optional
|
| 6 |
+
from ..core.dependencies import get_current_active_user
|
| 7 |
+
from ..db.database import get_db
|
| 8 |
+
from ..db.models import Order, Product, User
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
@router.get("/sales")
|
| 13 |
+
async def get_sales_analytics(
|
| 14 |
+
start_date: datetime = Query(default=None),
|
| 15 |
+
end_date: datetime = Query(default=None),
|
| 16 |
+
branch_id: Optional[int] = Query(None, description="Filter analytics by branch"),
|
| 17 |
+
current_user: User = Depends(get_current_active_user),
|
| 18 |
+
db: AsyncSession = Depends(get_db)
|
| 19 |
+
) -> Dict[str, Any]:
|
| 20 |
+
if not start_date:
|
| 21 |
+
start_date = datetime.now() - timedelta(days=30)
|
| 22 |
+
if not end_date:
|
| 23 |
+
end_date = datetime.now()
|
| 24 |
+
|
| 25 |
+
# Build query conditions
|
| 26 |
+
conditions = [
|
| 27 |
+
Order.created_at.between(start_date, end_date),
|
| 28 |
+
Order.status.in_(['completed', 'delivered'])
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
# Add branch filter
|
| 32 |
+
if branch_id:
|
| 33 |
+
if not current_user.is_superuser and branch_id != current_user.branch_id:
|
| 34 |
+
raise HTTPException(
|
| 35 |
+
status_code=403,
|
| 36 |
+
detail="You can only view analytics from your own branch"
|
| 37 |
+
)
|
| 38 |
+
conditions.append(Order.branch_id == branch_id)
|
| 39 |
+
elif not current_user.is_superuser:
|
| 40 |
+
# Non-superusers can only see their branch's analytics
|
| 41 |
+
conditions.append(Order.branch_id == current_user.branch_id)
|
| 42 |
+
|
| 43 |
+
# Daily sales query
|
| 44 |
+
stmt = select(
|
| 45 |
+
cast(Order.created_at, Date).label('date'),
|
| 46 |
+
func.sum(Order.total_amount).label('total_sales'),
|
| 47 |
+
func.count().label('order_count')
|
| 48 |
+
).where(
|
| 49 |
+
and_(*conditions)
|
| 50 |
+
).group_by(
|
| 51 |
+
cast(Order.created_at, Date)
|
| 52 |
+
).order_by(
|
| 53 |
+
cast(Order.created_at, Date)
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
result = await db.execute(stmt)
|
| 57 |
+
daily_sales = result.all()
|
| 58 |
+
|
| 59 |
+
# Calculate totals
|
| 60 |
+
total_revenue = sum(day.total_sales for day in daily_sales)
|
| 61 |
+
total_orders = sum(day.order_count for day in daily_sales)
|
| 62 |
+
avg_order_value = total_revenue / total_orders if total_orders > 0 else 0
|
| 63 |
+
|
| 64 |
+
return {
|
| 65 |
+
"daily_sales": [
|
| 66 |
+
{"date": day.date, "total_sales": day.total_sales, "order_count": day.order_count}
|
| 67 |
+
for day in daily_sales
|
| 68 |
+
],
|
| 69 |
+
"total_revenue": total_revenue,
|
| 70 |
+
"total_orders": total_orders,
|
| 71 |
+
"average_order_value": avg_order_value
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
@router.get("/products")
|
| 75 |
+
async def get_product_analytics(
|
| 76 |
+
branch_id: Optional[int] = Query(None, description="Filter analytics by branch"),
|
| 77 |
+
current_user: User = Depends(get_current_active_user),
|
| 78 |
+
db: AsyncSession = Depends(get_db)
|
| 79 |
+
) -> Dict[str, Any]:
|
| 80 |
+
# Build base conditions
|
| 81 |
+
conditions = []
|
| 82 |
+
|
| 83 |
+
# Add branch filter
|
| 84 |
+
if branch_id:
|
| 85 |
+
if not current_user.is_superuser and branch_id != current_user.branch_id:
|
| 86 |
+
raise HTTPException(
|
| 87 |
+
status_code=403,
|
| 88 |
+
detail="You can only view analytics from your own branch"
|
| 89 |
+
)
|
| 90 |
+
conditions.append(Product.branch_id == branch_id)
|
| 91 |
+
elif not current_user.is_superuser:
|
| 92 |
+
conditions.append(Product.branch_id == current_user.branch_id)
|
| 93 |
+
|
| 94 |
+
# Top selling products
|
| 95 |
+
stmt = select(
|
| 96 |
+
Product,
|
| 97 |
+
func.sum(Order.total_amount).label('total_revenue'),
|
| 98 |
+
func.count().label('total_orders')
|
| 99 |
+
).join(
|
| 100 |
+
Order, Product.id == Order.id
|
| 101 |
+
).where(
|
| 102 |
+
and_(*conditions)
|
| 103 |
+
).group_by(
|
| 104 |
+
Product.id
|
| 105 |
+
).order_by(
|
| 106 |
+
func.sum(Order.total_amount).desc()
|
| 107 |
+
).limit(10)
|
| 108 |
+
|
| 109 |
+
result = await db.execute(stmt)
|
| 110 |
+
top_products = result.all()
|
| 111 |
+
|
| 112 |
+
# Count total and low stock products
|
| 113 |
+
total_products = await db.scalar(
|
| 114 |
+
select(func.count()).select_from(Product).where(and_(*conditions))
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
low_stock_conditions = conditions + [Product.inventory_count < 10]
|
| 118 |
+
low_stock_count = await db.scalar(
|
| 119 |
+
select(func.count()).select_from(Product).where(and_(*low_stock_conditions))
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
return {
|
| 123 |
+
"top_products": [
|
| 124 |
+
{
|
| 125 |
+
"id": product.id,
|
| 126 |
+
"name": product.name,
|
| 127 |
+
"total_revenue": revenue,
|
| 128 |
+
"total_orders": orders
|
| 129 |
+
}
|
| 130 |
+
for product, revenue, orders in top_products
|
| 131 |
+
],
|
| 132 |
+
"total_products": total_products,
|
| 133 |
+
"low_stock_products": low_stock_count
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
@router.get("/customers")
|
| 137 |
+
async def get_customer_analytics(
|
| 138 |
+
branch_id: Optional[int] = Query(None, description="Filter analytics by branch"),
|
| 139 |
+
current_user: User = Depends(get_current_active_user),
|
| 140 |
+
db: AsyncSession = Depends(get_db)
|
| 141 |
+
) -> Dict[str, Any]:
|
| 142 |
+
# Build base conditions
|
| 143 |
+
conditions = []
|
| 144 |
+
|
| 145 |
+
# Add branch filter
|
| 146 |
+
if branch_id:
|
| 147 |
+
if not current_user.is_superuser and branch_id != current_user.branch_id:
|
| 148 |
+
raise HTTPException(
|
| 149 |
+
status_code=403,
|
| 150 |
+
detail="You can only view analytics from your own branch"
|
| 151 |
+
)
|
| 152 |
+
conditions.append(Order.branch_id == branch_id)
|
| 153 |
+
elif not current_user.is_superuser:
|
| 154 |
+
conditions.append(Order.branch_id == current_user.branch_id)
|
| 155 |
+
|
| 156 |
+
# Customer statistics
|
| 157 |
+
stmt = select(
|
| 158 |
+
User,
|
| 159 |
+
func.sum(Order.total_amount).label('total_spent'),
|
| 160 |
+
func.count().label('total_orders')
|
| 161 |
+
).join(
|
| 162 |
+
Order, User.id == Order.customer_id
|
| 163 |
+
).where(
|
| 164 |
+
and_(*conditions)
|
| 165 |
+
).group_by(
|
| 166 |
+
User.id
|
| 167 |
+
).order_by(
|
| 168 |
+
func.sum(Order.total_amount).desc()
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
result = await db.execute(stmt)
|
| 172 |
+
customer_data = result.all()
|
| 173 |
+
|
| 174 |
+
total_customers = len(customer_data)
|
| 175 |
+
total_revenue = sum(spent for _, spent, _ in customer_data)
|
| 176 |
+
avg_customer_value = total_revenue / total_customers if total_customers > 0 else 0
|
| 177 |
+
|
| 178 |
+
# Customer segments
|
| 179 |
+
segments = {
|
| 180 |
+
"high_value": len([c for c, spent, _ in customer_data if spent > 1000]),
|
| 181 |
+
"medium_value": len([c for c, spent, _ in customer_data if 500 <= spent <= 1000]),
|
| 182 |
+
"low_value": len([c for c, spent, _ in customer_data if spent < 500])
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
return {
|
| 186 |
+
"total_customers": total_customers,
|
| 187 |
+
"average_customer_value": avg_customer_value,
|
| 188 |
+
"customer_segments": segments,
|
| 189 |
+
"top_customers": [
|
| 190 |
+
{
|
| 191 |
+
"id": customer.id,
|
| 192 |
+
"email": customer.email,
|
| 193 |
+
"total_spent": spent,
|
| 194 |
+
"total_orders": orders
|
| 195 |
+
}
|
| 196 |
+
for customer, spent, orders in customer_data[:10] # Top 10 customers
|
| 197 |
+
]
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
@router.get("/dashboard")
|
| 201 |
+
async def get_dashboard_analytics(
|
| 202 |
+
branch_id: Optional[int] = Query(None, description="Filter analytics by branch"),
|
| 203 |
+
current_user: User = Depends(get_current_active_user),
|
| 204 |
+
db: AsyncSession = Depends(get_db)
|
| 205 |
+
) -> Dict[str, Any]:
|
| 206 |
+
"""Get a comprehensive dashboard with key metrics"""
|
| 207 |
+
# Get last 30 days of sales data
|
| 208 |
+
start_date = datetime.now() - timedelta(days=30)
|
| 209 |
+
end_date = datetime.now()
|
| 210 |
+
|
| 211 |
+
sales_data = await get_sales_analytics(start_date, end_date, branch_id, current_user, db)
|
| 212 |
+
product_data = await get_product_analytics(branch_id, current_user, db)
|
| 213 |
+
customer_data = await get_customer_analytics(branch_id, current_user, db)
|
| 214 |
+
|
| 215 |
+
return {
|
| 216 |
+
"sales_summary": {
|
| 217 |
+
"total_revenue": sales_data["total_revenue"],
|
| 218 |
+
"total_orders": sales_data["total_orders"],
|
| 219 |
+
"average_order_value": sales_data["average_order_value"],
|
| 220 |
+
"daily_sales": sales_data["daily_sales"][-7:] # Last 7 days
|
| 221 |
+
},
|
| 222 |
+
"product_summary": {
|
| 223 |
+
"total_products": product_data["total_products"],
|
| 224 |
+
"low_stock_products": product_data["low_stock_products"],
|
| 225 |
+
"top_selling_products": product_data["top_products"][:5] # Top 5 products
|
| 226 |
+
},
|
| 227 |
+
"customer_summary": {
|
| 228 |
+
"total_customers": customer_data["total_customers"],
|
| 229 |
+
"average_customer_value": customer_data["average_customer_value"],
|
| 230 |
+
"customer_segments": customer_data["customer_segments"]
|
| 231 |
+
}
|
| 232 |
+
}
|
app/api/auth.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
from sqlalchemy import select
|
| 5 |
+
from ..core.security import create_access_token, verify_password, get_password_hash
|
| 6 |
+
from ..db.database import get_db
|
| 7 |
+
from ..db.models import User
|
| 8 |
+
from ..db.schemas import UserInDB
|
| 9 |
+
from datetime import timedelta
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
| 14 |
+
|
| 15 |
+
@router.post("/login")
|
| 16 |
+
async def login(
|
| 17 |
+
form_data: OAuth2PasswordRequestForm = Depends(),
|
| 18 |
+
db: AsyncSession = Depends(get_db)
|
| 19 |
+
) -> Any:
|
| 20 |
+
stmt = select(User).where(User.email == form_data.username)
|
| 21 |
+
result = await db.execute(stmt)
|
| 22 |
+
user = result.scalar_one_or_none()
|
| 23 |
+
|
| 24 |
+
if not user:
|
| 25 |
+
raise HTTPException(
|
| 26 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 27 |
+
detail="Incorrect email or password",
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
if not verify_password(form_data.password, user.hashed_password):
|
| 31 |
+
raise HTTPException(
|
| 32 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 33 |
+
detail="Incorrect email or password",
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
access_token = create_access_token(user.id)
|
| 37 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
| 38 |
+
|
| 39 |
+
@router.post("/register", response_model=UserInDB)
|
| 40 |
+
async def register(
|
| 41 |
+
user_data: OAuth2PasswordRequestForm = Depends(),
|
| 42 |
+
db: AsyncSession = Depends(get_db)
|
| 43 |
+
) -> Any:
|
| 44 |
+
# Check if user exists
|
| 45 |
+
stmt = select(User).where(User.email == user_data.username)
|
| 46 |
+
result = await db.execute(stmt)
|
| 47 |
+
existing_user = result.scalar_one_or_none()
|
| 48 |
+
|
| 49 |
+
if existing_user:
|
| 50 |
+
raise HTTPException(
|
| 51 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 52 |
+
detail="Email already registered",
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# Create new user
|
| 56 |
+
user = User(
|
| 57 |
+
email=user_data.username,
|
| 58 |
+
hashed_password=get_password_hash(user_data.password),
|
| 59 |
+
full_name=user_data.username, # You might want to add this as a separate field in the form
|
| 60 |
+
username=user_data.username,
|
| 61 |
+
is_active=True,
|
| 62 |
+
is_superuser=False,
|
| 63 |
+
roles=["user"]
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
db.add(user)
|
| 67 |
+
await db.commit()
|
| 68 |
+
await db.refresh(user)
|
| 69 |
+
|
| 70 |
+
return user
|
app/api/branches.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy import select
|
| 4 |
+
from typing import List
|
| 5 |
+
from ..core.dependencies import get_current_superuser
|
| 6 |
+
from ..db.database import get_db
|
| 7 |
+
from ..db.models import Branch
|
| 8 |
+
from ..db.schemas import BranchCreate, BranchInDB
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
@router.post("/", response_model=BranchInDB)
|
| 13 |
+
async def create_branch(
|
| 14 |
+
branch: BranchCreate,
|
| 15 |
+
current_user = Depends(get_current_superuser),
|
| 16 |
+
db: AsyncSession = Depends(get_db)
|
| 17 |
+
) -> BranchInDB:
|
| 18 |
+
"""Create a new branch (superuser only)"""
|
| 19 |
+
db_branch = Branch(**branch.dict())
|
| 20 |
+
db.add(db_branch)
|
| 21 |
+
await db.commit()
|
| 22 |
+
await db.refresh(db_branch)
|
| 23 |
+
return db_branch
|
| 24 |
+
|
| 25 |
+
@router.get("/", response_model=List[BranchInDB])
|
| 26 |
+
async def list_branches(
|
| 27 |
+
skip: int = 0,
|
| 28 |
+
limit: int = 100,
|
| 29 |
+
db: AsyncSession = Depends(get_db)
|
| 30 |
+
) -> List[BranchInDB]:
|
| 31 |
+
"""List all branches"""
|
| 32 |
+
query = select(Branch).offset(skip).limit(limit)
|
| 33 |
+
result = await db.execute(query)
|
| 34 |
+
return result.scalars().all()
|
| 35 |
+
|
| 36 |
+
@router.get("/{branch_id}", response_model=BranchInDB)
|
| 37 |
+
async def get_branch(
|
| 38 |
+
branch_id: int,
|
| 39 |
+
db: AsyncSession = Depends(get_db)
|
| 40 |
+
) -> BranchInDB:
|
| 41 |
+
"""Get a specific branch"""
|
| 42 |
+
stmt = select(Branch).where(Branch.id == branch_id)
|
| 43 |
+
result = await db.execute(stmt)
|
| 44 |
+
branch = result.scalar_one_or_none()
|
| 45 |
+
|
| 46 |
+
if not branch:
|
| 47 |
+
raise HTTPException(status_code=404, detail="Branch not found")
|
| 48 |
+
return branch
|
| 49 |
+
|
| 50 |
+
@router.put("/{branch_id}", response_model=BranchInDB)
|
| 51 |
+
async def update_branch(
|
| 52 |
+
branch_id: int,
|
| 53 |
+
branch_update: BranchCreate,
|
| 54 |
+
current_user = Depends(get_current_superuser),
|
| 55 |
+
db: AsyncSession = Depends(get_db)
|
| 56 |
+
) -> BranchInDB:
|
| 57 |
+
"""Update a branch (superuser only)"""
|
| 58 |
+
stmt = select(Branch).where(Branch.id == branch_id)
|
| 59 |
+
result = await db.execute(stmt)
|
| 60 |
+
branch = result.scalar_one_or_none()
|
| 61 |
+
|
| 62 |
+
if not branch:
|
| 63 |
+
raise HTTPException(status_code=404, detail="Branch not found")
|
| 64 |
+
|
| 65 |
+
# Update branch fields
|
| 66 |
+
for field, value in branch_update.dict().items():
|
| 67 |
+
setattr(branch, field, value)
|
| 68 |
+
|
| 69 |
+
await db.commit()
|
| 70 |
+
await db.refresh(branch)
|
| 71 |
+
return branch
|
| 72 |
+
|
| 73 |
+
@router.delete("/{branch_id}")
|
| 74 |
+
async def delete_branch(
|
| 75 |
+
branch_id: int,
|
| 76 |
+
current_user = Depends(get_current_superuser),
|
| 77 |
+
db: AsyncSession = Depends(get_db)
|
| 78 |
+
):
|
| 79 |
+
"""Delete a branch (superuser only)"""
|
| 80 |
+
stmt = select(Branch).where(Branch.id == branch_id)
|
| 81 |
+
result = await db.execute(stmt)
|
| 82 |
+
branch = result.scalar_one_or_none()
|
| 83 |
+
|
| 84 |
+
if not branch:
|
| 85 |
+
raise HTTPException(status_code=404, detail="Branch not found")
|
| 86 |
+
|
| 87 |
+
await db.delete(branch)
|
| 88 |
+
await db.commit()
|
| 89 |
+
return {"status": "success", "message": "Branch deleted"}
|
app/api/calendar.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy import select, or_
|
| 4 |
+
from typing import List, Dict, Any
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
from ..core.dependencies import get_current_active_user
|
| 7 |
+
from ..db.database import get_db
|
| 8 |
+
from ..db.models import Event, User
|
| 9 |
+
from ..db.schemas import EventCreate, EventUpdate, EventInDB, RecurringEventCreate
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
@router.post("/events", response_model=EventInDB)
|
| 14 |
+
async def create_event(
|
| 15 |
+
event: EventCreate,
|
| 16 |
+
current_user: User = Depends(get_current_active_user),
|
| 17 |
+
db: AsyncSession = Depends(get_db)
|
| 18 |
+
) -> EventInDB:
|
| 19 |
+
"""Create a new calendar event"""
|
| 20 |
+
db_event = Event(
|
| 21 |
+
user_id=current_user.id,
|
| 22 |
+
title=event.title,
|
| 23 |
+
description=event.description,
|
| 24 |
+
start_time=event.start_time,
|
| 25 |
+
end_time=event.end_time,
|
| 26 |
+
attendees=event.attendees,
|
| 27 |
+
is_all_day=event.is_all_day,
|
| 28 |
+
reminder_minutes=event.reminder_minutes,
|
| 29 |
+
status="scheduled",
|
| 30 |
+
attendee_responses={}
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
db.add(db_event)
|
| 34 |
+
await db.commit()
|
| 35 |
+
await db.refresh(db_event)
|
| 36 |
+
return db_event
|
| 37 |
+
|
| 38 |
+
@router.get("/events", response_model=List[EventInDB])
|
| 39 |
+
async def get_events(
|
| 40 |
+
start_date: datetime = Query(default=None),
|
| 41 |
+
end_date: datetime = Query(default=None),
|
| 42 |
+
include_attendee_events: bool = True,
|
| 43 |
+
current_user: User = Depends(get_current_active_user),
|
| 44 |
+
db: AsyncSession = Depends(get_db)
|
| 45 |
+
) -> List[EventInDB]:
|
| 46 |
+
"""Get user's events within a date range"""
|
| 47 |
+
if not start_date:
|
| 48 |
+
start_date = datetime.now()
|
| 49 |
+
if not end_date:
|
| 50 |
+
end_date = start_date + timedelta(days=30)
|
| 51 |
+
|
| 52 |
+
query = select(Event).where(
|
| 53 |
+
Event.start_time >= start_date,
|
| 54 |
+
Event.end_time <= end_date
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
if include_attendee_events:
|
| 58 |
+
query = query.where(or_(
|
| 59 |
+
Event.user_id == current_user.id,
|
| 60 |
+
Event.attendees.contains([str(current_user.id)])
|
| 61 |
+
))
|
| 62 |
+
else:
|
| 63 |
+
query = query.where(Event.user_id == current_user.id)
|
| 64 |
+
|
| 65 |
+
query = query.order_by(Event.start_time)
|
| 66 |
+
result = await db.execute(query)
|
| 67 |
+
return result.scalars().all()
|
| 68 |
+
|
| 69 |
+
@router.put("/events/{event_id}", response_model=EventInDB)
|
| 70 |
+
async def update_event(
|
| 71 |
+
event_id: int,
|
| 72 |
+
event_update: EventUpdate,
|
| 73 |
+
current_user: User = Depends(get_current_active_user),
|
| 74 |
+
db: AsyncSession = Depends(get_db)
|
| 75 |
+
) -> EventInDB:
|
| 76 |
+
"""Update an event"""
|
| 77 |
+
stmt = select(Event).where(
|
| 78 |
+
Event.id == event_id,
|
| 79 |
+
Event.user_id == current_user.id
|
| 80 |
+
)
|
| 81 |
+
result = await db.execute(stmt)
|
| 82 |
+
event = result.scalar_one_or_none()
|
| 83 |
+
|
| 84 |
+
if not event:
|
| 85 |
+
raise HTTPException(
|
| 86 |
+
status_code=404,
|
| 87 |
+
detail="Event not found or you don't have permission to update it"
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# Update event fields
|
| 91 |
+
update_data = event_update.dict(exclude_unset=True)
|
| 92 |
+
for field, value in update_data.items():
|
| 93 |
+
setattr(event, field, value)
|
| 94 |
+
|
| 95 |
+
event.updated_at = datetime.utcnow()
|
| 96 |
+
await db.commit()
|
| 97 |
+
await db.refresh(event)
|
| 98 |
+
return event
|
| 99 |
+
|
| 100 |
+
@router.delete("/events/{event_id}")
|
| 101 |
+
async def delete_event(
|
| 102 |
+
event_id: int,
|
| 103 |
+
current_user: User = Depends(get_current_active_user),
|
| 104 |
+
db: AsyncSession = Depends(get_db)
|
| 105 |
+
) -> Dict[str, bool]:
|
| 106 |
+
"""Delete an event"""
|
| 107 |
+
stmt = select(Event).where(
|
| 108 |
+
Event.id == event_id,
|
| 109 |
+
Event.user_id == current_user.id
|
| 110 |
+
)
|
| 111 |
+
result = await db.execute(stmt)
|
| 112 |
+
event = result.scalar_one_or_none()
|
| 113 |
+
|
| 114 |
+
if not event:
|
| 115 |
+
raise HTTPException(
|
| 116 |
+
status_code=404,
|
| 117 |
+
detail="Event not found or you don't have permission to delete it"
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
await db.delete(event)
|
| 121 |
+
await db.commit()
|
| 122 |
+
return {"success": True}
|
| 123 |
+
|
| 124 |
+
@router.post("/events/{event_id}/respond")
|
| 125 |
+
async def respond_to_event(
|
| 126 |
+
event_id: int,
|
| 127 |
+
response: str,
|
| 128 |
+
current_user: User = Depends(get_current_active_user),
|
| 129 |
+
db: AsyncSession = Depends(get_db)
|
| 130 |
+
) -> Dict[str, bool]:
|
| 131 |
+
"""Respond to an event invitation"""
|
| 132 |
+
if response not in ["accepted", "declined", "maybe"]:
|
| 133 |
+
raise HTTPException(
|
| 134 |
+
status_code=400,
|
| 135 |
+
detail="Invalid response. Must be one of: accepted, declined, maybe"
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
stmt = select(Event).where(
|
| 139 |
+
Event.id == event_id,
|
| 140 |
+
Event.attendees.contains([str(current_user.id)])
|
| 141 |
+
)
|
| 142 |
+
result = await db.execute(stmt)
|
| 143 |
+
event = result.scalar_one_or_none()
|
| 144 |
+
|
| 145 |
+
if not event:
|
| 146 |
+
raise HTTPException(
|
| 147 |
+
status_code=404,
|
| 148 |
+
detail="Event not found or you are not invited to this event"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
# Update the response in the attendee_responses dictionary
|
| 152 |
+
event.attendee_responses[str(current_user.id)] = response
|
| 153 |
+
event.updated_at = datetime.utcnow()
|
| 154 |
+
|
| 155 |
+
await db.commit()
|
| 156 |
+
return {"success": True}
|
app/api/files.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
|
| 2 |
+
from fastapi.responses import FileResponse
|
| 3 |
+
from typing import List
|
| 4 |
+
from ..core.dependencies import get_current_active_user
|
| 5 |
+
from ..utils.file_storage import file_storage
|
| 6 |
+
from ..utils.logger import logger
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
@router.post("/upload")
|
| 12 |
+
async def upload_file(
|
| 13 |
+
file: UploadFile = File(...),
|
| 14 |
+
category: str = "documents",
|
| 15 |
+
current_user = Depends(get_current_active_user)
|
| 16 |
+
) -> dict:
|
| 17 |
+
try:
|
| 18 |
+
file_path = await file_storage.save_file(file, category)
|
| 19 |
+
if not file_path:
|
| 20 |
+
raise HTTPException(status_code=400, detail="Failed to upload file")
|
| 21 |
+
|
| 22 |
+
return {
|
| 23 |
+
"filename": file.filename,
|
| 24 |
+
"stored_path": file_path,
|
| 25 |
+
"url": file_storage.get_file_url(file_path)
|
| 26 |
+
}
|
| 27 |
+
except ValueError as e:
|
| 28 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 29 |
+
except Exception as e:
|
| 30 |
+
logger.error(f"File upload error: {str(e)}")
|
| 31 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
| 32 |
+
|
| 33 |
+
@router.delete("/{file_path:path}")
|
| 34 |
+
async def delete_file(
|
| 35 |
+
file_path: str,
|
| 36 |
+
current_user = Depends(get_current_active_user)
|
| 37 |
+
) -> dict:
|
| 38 |
+
success = await file_storage.delete_file(file_path)
|
| 39 |
+
if not success:
|
| 40 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 41 |
+
|
| 42 |
+
return {"status": "success", "message": "File deleted successfully"}
|
| 43 |
+
|
| 44 |
+
@router.get("/{file_path:path}")
|
| 45 |
+
async def get_file(
|
| 46 |
+
file_path: str,
|
| 47 |
+
current_user = Depends(get_current_active_user)
|
| 48 |
+
):
|
| 49 |
+
full_path = Path("uploads") / file_path
|
| 50 |
+
if not full_path.exists():
|
| 51 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 52 |
+
|
| 53 |
+
return FileResponse(str(full_path))
|
app/api/maintenance.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy import select, delete, func
|
| 4 |
+
from typing import Dict, Any, List
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
from ..core.dependencies import get_current_active_user
|
| 7 |
+
from ..db.database import get_db
|
| 8 |
+
from ..db.models import User, Order, Notification, Event
|
| 9 |
+
from ..utils.logger import logger
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
@router.post("/sessions/cleanup")
|
| 14 |
+
async def cleanup_sessions(
|
| 15 |
+
current_user: User = Depends(get_current_active_user),
|
| 16 |
+
db: AsyncSession = Depends(get_db)
|
| 17 |
+
) -> Dict[str, int]:
|
| 18 |
+
"""Manually trigger session cleanup"""
|
| 19 |
+
if "admin" not in current_user.roles:
|
| 20 |
+
raise HTTPException(
|
| 21 |
+
status_code=403,
|
| 22 |
+
detail="Only administrators can perform maintenance operations"
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
cutoff_date = datetime.utcnow() - timedelta(days=7)
|
| 26 |
+
stmt = delete(Event).where(Event.created_at < cutoff_date)
|
| 27 |
+
result = await db.execute(stmt)
|
| 28 |
+
await db.commit()
|
| 29 |
+
|
| 30 |
+
return {"deleted_sessions": result.rowcount}
|
| 31 |
+
|
| 32 |
+
@router.post("/data/archive")
|
| 33 |
+
async def archive_data(
|
| 34 |
+
current_user: User = Depends(get_current_active_user),
|
| 35 |
+
db: AsyncSession = Depends(get_db)
|
| 36 |
+
) -> Dict[str, int]:
|
| 37 |
+
"""Manually trigger data archiving"""
|
| 38 |
+
if "admin" not in current_user.roles:
|
| 39 |
+
raise HTTPException(
|
| 40 |
+
status_code=403,
|
| 41 |
+
detail="Only administrators can perform maintenance operations"
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
archive_date = datetime.utcnow() - timedelta(days=365)
|
| 45 |
+
archived = {}
|
| 46 |
+
|
| 47 |
+
# Archive old orders
|
| 48 |
+
orders_stmt = delete(Order).where(
|
| 49 |
+
Order.created_at < archive_date,
|
| 50 |
+
Order.status.in_(["delivered", "cancelled"])
|
| 51 |
+
)
|
| 52 |
+
orders_result = await db.execute(orders_stmt)
|
| 53 |
+
archived["orders"] = orders_result.rowcount
|
| 54 |
+
|
| 55 |
+
# Archive old notifications
|
| 56 |
+
notif_stmt = delete(Notification).where(
|
| 57 |
+
Notification.created_at < archive_date,
|
| 58 |
+
Notification.read == True
|
| 59 |
+
)
|
| 60 |
+
notif_result = await db.execute(notif_stmt)
|
| 61 |
+
archived["notifications"] = notif_result.rowcount
|
| 62 |
+
|
| 63 |
+
await db.commit()
|
| 64 |
+
return archived
|
| 65 |
+
|
| 66 |
+
@router.get("/health")
|
| 67 |
+
async def check_health(
|
| 68 |
+
current_user: User = Depends(get_current_active_user),
|
| 69 |
+
db: AsyncSession = Depends(get_db)
|
| 70 |
+
) -> Dict[str, Any]:
|
| 71 |
+
"""Check system health metrics"""
|
| 72 |
+
if "admin" not in current_user.roles:
|
| 73 |
+
raise HTTPException(
|
| 74 |
+
status_code=403,
|
| 75 |
+
detail="Only administrators can view system health"
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
# Check database connection
|
| 80 |
+
await db.execute(select(1))
|
| 81 |
+
|
| 82 |
+
# Get database statistics
|
| 83 |
+
total_users = await db.scalar(select(func.count()).select_from(User))
|
| 84 |
+
total_orders = await db.scalar(select(func.count()).select_from(Order))
|
| 85 |
+
total_notifications = await db.scalar(select(func.count()).select_from(Notification))
|
| 86 |
+
|
| 87 |
+
return {
|
| 88 |
+
"status": "healthy",
|
| 89 |
+
"timestamp": datetime.utcnow(),
|
| 90 |
+
"database": {
|
| 91 |
+
"connected": True,
|
| 92 |
+
"total_users": total_users,
|
| 93 |
+
"total_orders": total_orders,
|
| 94 |
+
"total_notifications": total_notifications
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
except Exception as e:
|
| 98 |
+
logger.error(f"Health check error: {str(e)}")
|
| 99 |
+
return {
|
| 100 |
+
"status": "unhealthy",
|
| 101 |
+
"error": str(e),
|
| 102 |
+
"timestamp": datetime.utcnow()
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
@router.post("/database/maintenance")
|
| 106 |
+
async def perform_db_maintenance(
|
| 107 |
+
current_user: User = Depends(get_current_active_user),
|
| 108 |
+
db: AsyncSession = Depends(get_db)
|
| 109 |
+
) -> Dict[str, Any]:
|
| 110 |
+
"""Manually trigger database maintenance"""
|
| 111 |
+
if "admin" not in current_user.roles:
|
| 112 |
+
raise HTTPException(
|
| 113 |
+
status_code=403,
|
| 114 |
+
detail="Only administrators can perform maintenance operations"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
try:
|
| 118 |
+
# Cleanup expired sessions
|
| 119 |
+
await cleanup_sessions(current_user, db)
|
| 120 |
+
|
| 121 |
+
# Run VACUUM ANALYZE (requires raw SQL)
|
| 122 |
+
await db.execute("VACUUM ANALYZE;")
|
| 123 |
+
|
| 124 |
+
return {
|
| 125 |
+
"status": "success",
|
| 126 |
+
"message": "Database maintenance completed successfully"
|
| 127 |
+
}
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.error(f"Database maintenance error: {str(e)}")
|
| 130 |
+
raise HTTPException(
|
| 131 |
+
status_code=500,
|
| 132 |
+
detail=f"Database maintenance failed: {str(e)}"
|
| 133 |
+
)
|
app/api/notifications.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy import select, update
|
| 4 |
+
from typing import List, Dict, Any, Optional
|
| 5 |
+
from ..core.dependencies import get_current_active_user
|
| 6 |
+
from ..db.database import get_db
|
| 7 |
+
from ..db.models import Notification, User
|
| 8 |
+
from ..db.schemas import NotificationCreate, NotificationInDB
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
@router.get("/", response_model=List[NotificationInDB])
|
| 13 |
+
async def get_notifications(
|
| 14 |
+
skip: int = Query(0, ge=0),
|
| 15 |
+
limit: int = Query(50, ge=1, le=100),
|
| 16 |
+
unread_only: bool = False,
|
| 17 |
+
current_user: User = Depends(get_current_active_user),
|
| 18 |
+
db: AsyncSession = Depends(get_db)
|
| 19 |
+
) -> List[NotificationInDB]:
|
| 20 |
+
"""Get user's notifications"""
|
| 21 |
+
query = select(Notification).where(Notification.user_id == current_user.id)
|
| 22 |
+
|
| 23 |
+
if unread_only:
|
| 24 |
+
query = query.where(Notification.read == False)
|
| 25 |
+
|
| 26 |
+
query = query.order_by(Notification.created_at.desc()).offset(skip).limit(limit)
|
| 27 |
+
result = await db.execute(query)
|
| 28 |
+
return result.scalars().all()
|
| 29 |
+
|
| 30 |
+
@router.post("/mark-read/{notification_id}", response_model=NotificationInDB)
|
| 31 |
+
async def mark_notification_read(
|
| 32 |
+
notification_id: int,
|
| 33 |
+
current_user: User = Depends(get_current_active_user),
|
| 34 |
+
db: AsyncSession = Depends(get_db)
|
| 35 |
+
) -> NotificationInDB:
|
| 36 |
+
"""Mark a notification as read"""
|
| 37 |
+
stmt = select(Notification).where(
|
| 38 |
+
Notification.id == notification_id,
|
| 39 |
+
Notification.user_id == current_user.id
|
| 40 |
+
)
|
| 41 |
+
result = await db.execute(stmt)
|
| 42 |
+
notification = result.scalar_one_or_none()
|
| 43 |
+
|
| 44 |
+
if not notification:
|
| 45 |
+
raise HTTPException(status_code=404, detail="Notification not found")
|
| 46 |
+
|
| 47 |
+
notification.read = True
|
| 48 |
+
await db.commit()
|
| 49 |
+
await db.refresh(notification)
|
| 50 |
+
return notification
|
| 51 |
+
|
| 52 |
+
@router.post("/mark-all-read")
|
| 53 |
+
async def mark_all_notifications_read(
|
| 54 |
+
current_user: User = Depends(get_current_active_user),
|
| 55 |
+
db: AsyncSession = Depends(get_db)
|
| 56 |
+
) -> Dict[str, int]:
|
| 57 |
+
"""Mark all notifications as read"""
|
| 58 |
+
stmt = update(Notification).where(
|
| 59 |
+
Notification.user_id == current_user.id,
|
| 60 |
+
Notification.read == False
|
| 61 |
+
).values(read=True)
|
| 62 |
+
|
| 63 |
+
result = await db.execute(stmt)
|
| 64 |
+
await db.commit()
|
| 65 |
+
return {"marked_count": result.rowcount}
|
| 66 |
+
|
| 67 |
+
@router.delete("/{notification_id}")
|
| 68 |
+
async def delete_notification(
|
| 69 |
+
notification_id: int,
|
| 70 |
+
current_user: User = Depends(get_current_active_user),
|
| 71 |
+
db: AsyncSession = Depends(get_db)
|
| 72 |
+
) -> Dict[str, bool]:
|
| 73 |
+
"""Delete a notification"""
|
| 74 |
+
stmt = select(Notification).where(
|
| 75 |
+
Notification.id == notification_id,
|
| 76 |
+
Notification.user_id == current_user.id
|
| 77 |
+
)
|
| 78 |
+
result = await db.execute(stmt)
|
| 79 |
+
notification = result.scalar_one_or_none()
|
| 80 |
+
|
| 81 |
+
if not notification:
|
| 82 |
+
raise HTTPException(status_code=404, detail="Notification not found")
|
| 83 |
+
|
| 84 |
+
await db.delete(notification)
|
| 85 |
+
await db.commit()
|
| 86 |
+
return {"success": True}
|
app/api/orders.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, status, Depends, Query
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy import select
|
| 4 |
+
from typing import List, Optional
|
| 5 |
+
from ..core.dependencies import get_current_active_user
|
| 6 |
+
from ..db.database import get_db
|
| 7 |
+
from ..db.models import Order, Product, OrderItem, User
|
| 8 |
+
from ..db.schemas import OrderCreate, OrderInDB
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
@router.post("/", response_model=OrderInDB)
|
| 14 |
+
async def create_order(
|
| 15 |
+
order: OrderCreate,
|
| 16 |
+
current_user: User = Depends(get_current_active_user),
|
| 17 |
+
db: AsyncSession = Depends(get_db)
|
| 18 |
+
) -> OrderInDB:
|
| 19 |
+
# Ensure user belongs to the branch they're creating the order for
|
| 20 |
+
if current_user.branch_id != order.branch_id and not current_user.is_superuser:
|
| 21 |
+
raise HTTPException(
|
| 22 |
+
status_code=403,
|
| 23 |
+
detail="You can only create orders for your own branch"
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# Calculate total and validate products
|
| 27 |
+
total = 0
|
| 28 |
+
order_items = []
|
| 29 |
+
|
| 30 |
+
for item in order.items:
|
| 31 |
+
# Get product
|
| 32 |
+
stmt = select(Product).where(
|
| 33 |
+
Product.id == item.product_id,
|
| 34 |
+
Product.branch_id == order.branch_id # Ensure product belongs to the same branch
|
| 35 |
+
)
|
| 36 |
+
result = await db.execute(stmt)
|
| 37 |
+
product = result.scalar_one_or_none()
|
| 38 |
+
|
| 39 |
+
if not product:
|
| 40 |
+
raise HTTPException(
|
| 41 |
+
status_code=404,
|
| 42 |
+
detail=f"Product {item.product_id} not found in this branch"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
if product.inventory_count < item.quantity:
|
| 46 |
+
raise HTTPException(
|
| 47 |
+
status_code=400,
|
| 48 |
+
detail=f"Insufficient inventory for product {item.product_id}"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# Update inventory
|
| 52 |
+
product.inventory_count -= item.quantity
|
| 53 |
+
total += product.price * item.quantity
|
| 54 |
+
|
| 55 |
+
# Create order item
|
| 56 |
+
order_item = OrderItem(
|
| 57 |
+
product_id=item.product_id,
|
| 58 |
+
quantity=item.quantity,
|
| 59 |
+
price=product.price
|
| 60 |
+
)
|
| 61 |
+
order_items.append(order_item)
|
| 62 |
+
|
| 63 |
+
# Create order
|
| 64 |
+
db_order = Order(
|
| 65 |
+
customer_id=order.customer_id,
|
| 66 |
+
branch_id=order.branch_id,
|
| 67 |
+
total_amount=total,
|
| 68 |
+
status="pending",
|
| 69 |
+
items=order_items
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
db.add(db_order)
|
| 73 |
+
await db.commit()
|
| 74 |
+
await db.refresh(db_order)
|
| 75 |
+
return db_order
|
| 76 |
+
|
| 77 |
+
@router.get("/", response_model=List[OrderInDB])
|
| 78 |
+
async def list_orders(
|
| 79 |
+
skip: int = 0,
|
| 80 |
+
limit: int = 10,
|
| 81 |
+
status: Optional[str] = None,
|
| 82 |
+
branch_id: Optional[int] = Query(None, description="Filter orders by branch"),
|
| 83 |
+
current_user: User = Depends(get_current_active_user),
|
| 84 |
+
db: AsyncSession = Depends(get_db)
|
| 85 |
+
) -> List[OrderInDB]:
|
| 86 |
+
query = select(Order)
|
| 87 |
+
|
| 88 |
+
# Filter by status if provided
|
| 89 |
+
if status:
|
| 90 |
+
query = query.where(Order.status == status)
|
| 91 |
+
|
| 92 |
+
# Filter by branch if provided, otherwise use user's branch
|
| 93 |
+
if branch_id:
|
| 94 |
+
if not current_user.is_superuser and branch_id != current_user.branch_id:
|
| 95 |
+
raise HTTPException(
|
| 96 |
+
status_code=403,
|
| 97 |
+
detail="You can only view orders from your own branch"
|
| 98 |
+
)
|
| 99 |
+
query = query.where(Order.branch_id == branch_id)
|
| 100 |
+
elif not current_user.is_superuser:
|
| 101 |
+
# Non-superusers can only see orders from their branch
|
| 102 |
+
query = query.where(Order.branch_id == current_user.branch_id)
|
| 103 |
+
|
| 104 |
+
query = query.offset(skip).limit(limit)
|
| 105 |
+
result = await db.execute(query)
|
| 106 |
+
return result.scalars().all()
|
| 107 |
+
|
| 108 |
+
@router.get("/{order_id}", response_model=OrderInDB)
|
| 109 |
+
async def get_order(
|
| 110 |
+
order_id: int,
|
| 111 |
+
current_user: User = Depends(get_current_active_user),
|
| 112 |
+
db: AsyncSession = Depends(get_db)
|
| 113 |
+
) -> OrderInDB:
|
| 114 |
+
stmt = select(Order).where(Order.id == order_id)
|
| 115 |
+
result = await db.execute(stmt)
|
| 116 |
+
order = result.scalar_one_or_none()
|
| 117 |
+
|
| 118 |
+
if not order:
|
| 119 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 120 |
+
|
| 121 |
+
# Check if user has access to this order's branch
|
| 122 |
+
if not current_user.is_superuser and order.branch_id != current_user.branch_id:
|
| 123 |
+
raise HTTPException(status_code=403, detail="You cannot access orders from other branches")
|
| 124 |
+
|
| 125 |
+
return order
|
| 126 |
+
|
| 127 |
+
@router.put("/{order_id}/status", response_model=OrderInDB)
|
| 128 |
+
async def update_order_status(
|
| 129 |
+
order_id: int,
|
| 130 |
+
status: str,
|
| 131 |
+
current_user: User = Depends(get_current_active_user),
|
| 132 |
+
db: AsyncSession = Depends(get_db)
|
| 133 |
+
) -> OrderInDB:
|
| 134 |
+
valid_statuses = ["pending", "processing", "shipped", "delivered", "cancelled"]
|
| 135 |
+
if status not in valid_statuses:
|
| 136 |
+
raise HTTPException(status_code=400, detail="Invalid status")
|
| 137 |
+
|
| 138 |
+
stmt = select(Order).where(Order.id == order_id)
|
| 139 |
+
result = await db.execute(stmt)
|
| 140 |
+
order = result.scalar_one_or_none()
|
| 141 |
+
|
| 142 |
+
if not order:
|
| 143 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 144 |
+
|
| 145 |
+
# Check if user has access to this order's branch
|
| 146 |
+
if not current_user.is_superuser and order.branch_id != current_user.branch_id:
|
| 147 |
+
raise HTTPException(status_code=403, detail="You cannot modify orders from other branches")
|
| 148 |
+
|
| 149 |
+
order.status = status
|
| 150 |
+
order.updated_at = datetime.utcnow()
|
| 151 |
+
|
| 152 |
+
await db.commit()
|
| 153 |
+
await db.refresh(order)
|
| 154 |
+
return order
|
| 155 |
+
|
| 156 |
+
@router.delete("/{order_id}")
|
| 157 |
+
async def delete_order(
|
| 158 |
+
order_id: int,
|
| 159 |
+
current_user: User = Depends(get_current_active_user),
|
| 160 |
+
db: AsyncSession = Depends(get_db)
|
| 161 |
+
):
|
| 162 |
+
# Get the order
|
| 163 |
+
stmt = select(Order).where(Order.id == order_id)
|
| 164 |
+
result = await db.execute(stmt)
|
| 165 |
+
order = result.scalar_one_or_none()
|
| 166 |
+
|
| 167 |
+
if not order:
|
| 168 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 169 |
+
|
| 170 |
+
# Check if user has access to this order's branch
|
| 171 |
+
if not current_user.is_superuser and order.branch_id != current_user.branch_id:
|
| 172 |
+
raise HTTPException(status_code=403, detail="You cannot delete orders from other branches")
|
| 173 |
+
|
| 174 |
+
# Restore inventory for each product
|
| 175 |
+
for item in order.items:
|
| 176 |
+
product_stmt = select(Product).where(Product.id == item.product_id)
|
| 177 |
+
product_result = await db.execute(product_stmt)
|
| 178 |
+
product = product_result.scalar_one_or_none()
|
| 179 |
+
|
| 180 |
+
if product:
|
| 181 |
+
product.inventory_count += item.quantity
|
| 182 |
+
|
| 183 |
+
await db.delete(order)
|
| 184 |
+
await db.commit()
|
| 185 |
+
|
| 186 |
+
return {"status": "success", "message": "Order deleted and inventory restored"}
|
app/api/products.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, status, Depends, Query
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy import select
|
| 4 |
+
from typing import List, Optional
|
| 5 |
+
from ..core.dependencies import get_current_active_user
|
| 6 |
+
from ..db.database import get_db
|
| 7 |
+
from ..db.models import Product, User
|
| 8 |
+
from ..db.schemas import ProductCreate, ProductInDB
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
@router.post("/", response_model=ProductInDB)
|
| 13 |
+
async def create_product(
|
| 14 |
+
product: ProductCreate,
|
| 15 |
+
current_user: User = Depends(get_current_active_user),
|
| 16 |
+
db: AsyncSession = Depends(get_db)
|
| 17 |
+
) -> ProductInDB:
|
| 18 |
+
# Ensure user belongs to the branch they're creating the product for
|
| 19 |
+
if current_user.branch_id != product.branch_id and not current_user.is_superuser:
|
| 20 |
+
raise HTTPException(
|
| 21 |
+
status_code=403,
|
| 22 |
+
detail="You can only create products for your own branch"
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
db_product = Product(**product.dict())
|
| 26 |
+
db.add(db_product)
|
| 27 |
+
await db.commit()
|
| 28 |
+
await db.refresh(db_product)
|
| 29 |
+
return db_product
|
| 30 |
+
|
| 31 |
+
@router.get("/", response_model=List[ProductInDB])
|
| 32 |
+
async def list_products(
|
| 33 |
+
skip: int = 0,
|
| 34 |
+
limit: int = 10,
|
| 35 |
+
category: Optional[str] = None,
|
| 36 |
+
branch_id: Optional[int] = Query(None, description="Filter products by branch"),
|
| 37 |
+
current_user: User = Depends(get_current_active_user),
|
| 38 |
+
db: AsyncSession = Depends(get_db)
|
| 39 |
+
) -> List[ProductInDB]:
|
| 40 |
+
query = select(Product)
|
| 41 |
+
|
| 42 |
+
# Filter by category if provided
|
| 43 |
+
if category:
|
| 44 |
+
query = query.where(Product.category == category)
|
| 45 |
+
|
| 46 |
+
# Filter by branch if provided, otherwise use user's branch
|
| 47 |
+
if branch_id:
|
| 48 |
+
if not current_user.is_superuser and branch_id != current_user.branch_id:
|
| 49 |
+
raise HTTPException(
|
| 50 |
+
status_code=403,
|
| 51 |
+
detail="You can only view products from your own branch"
|
| 52 |
+
)
|
| 53 |
+
query = query.where(Product.branch_id == branch_id)
|
| 54 |
+
elif not current_user.is_superuser:
|
| 55 |
+
# Non-superusers can only see products from their branch
|
| 56 |
+
query = query.where(Product.branch_id == current_user.branch_id)
|
| 57 |
+
|
| 58 |
+
query = query.offset(skip).limit(limit)
|
| 59 |
+
result = await db.execute(query)
|
| 60 |
+
return result.scalars().all()
|
| 61 |
+
|
| 62 |
+
@router.get("/{product_id}", response_model=ProductInDB)
|
| 63 |
+
async def get_product(
|
| 64 |
+
product_id: int,
|
| 65 |
+
current_user: User = Depends(get_current_active_user),
|
| 66 |
+
db: AsyncSession = Depends(get_db)
|
| 67 |
+
) -> ProductInDB:
|
| 68 |
+
stmt = select(Product).where(Product.id == product_id)
|
| 69 |
+
result = await db.execute(stmt)
|
| 70 |
+
product = result.scalar_one_or_none()
|
| 71 |
+
|
| 72 |
+
if not product:
|
| 73 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 74 |
+
|
| 75 |
+
# Check if user has access to this product's branch
|
| 76 |
+
if not current_user.is_superuser and product.branch_id != current_user.branch_id:
|
| 77 |
+
raise HTTPException(status_code=403, detail="You cannot access products from other branches")
|
| 78 |
+
|
| 79 |
+
return product
|
| 80 |
+
|
| 81 |
+
@router.put("/{product_id}", response_model=ProductInDB)
|
| 82 |
+
async def update_product(
|
| 83 |
+
product_id: int,
|
| 84 |
+
product_update: ProductCreate,
|
| 85 |
+
current_user: User = Depends(get_current_active_user),
|
| 86 |
+
db: AsyncSession = Depends(get_db)
|
| 87 |
+
) -> ProductInDB:
|
| 88 |
+
stmt = select(Product).where(Product.id == product_id)
|
| 89 |
+
result = await db.execute(stmt)
|
| 90 |
+
product = result.scalar_one_or_none()
|
| 91 |
+
|
| 92 |
+
if not product:
|
| 93 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 94 |
+
|
| 95 |
+
# Check if user has access to this product's branch
|
| 96 |
+
if not current_user.is_superuser and product.branch_id != current_user.branch_id:
|
| 97 |
+
raise HTTPException(status_code=403, detail="You cannot modify products from other branches")
|
| 98 |
+
|
| 99 |
+
# Ensure the branch isn't being changed to a different branch
|
| 100 |
+
if product_update.branch_id != product.branch_id:
|
| 101 |
+
raise HTTPException(status_code=400, detail="Cannot change product's branch")
|
| 102 |
+
|
| 103 |
+
# Update product fields
|
| 104 |
+
update_data = product_update.dict(exclude_unset=True)
|
| 105 |
+
for field, value in update_data.items():
|
| 106 |
+
setattr(product, field, value)
|
| 107 |
+
|
| 108 |
+
await db.commit()
|
| 109 |
+
await db.refresh(product)
|
| 110 |
+
return product
|
| 111 |
+
|
| 112 |
+
@router.delete("/{product_id}")
|
| 113 |
+
async def delete_product(
|
| 114 |
+
product_id: int,
|
| 115 |
+
current_user: User = Depends(get_current_active_user),
|
| 116 |
+
db: AsyncSession = Depends(get_db)
|
| 117 |
+
):
|
| 118 |
+
stmt = select(Product).where(Product.id == product_id)
|
| 119 |
+
result = await db.execute(stmt)
|
| 120 |
+
product = result.scalar_one_or_none()
|
| 121 |
+
|
| 122 |
+
if not product:
|
| 123 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 124 |
+
|
| 125 |
+
# Check if user has access to this product's branch
|
| 126 |
+
if not current_user.is_superuser and product.branch_id != current_user.branch_id:
|
| 127 |
+
raise HTTPException(status_code=403, detail="You cannot delete products from other branches")
|
| 128 |
+
|
| 129 |
+
await db.delete(product)
|
| 130 |
+
await db.commit()
|
| 131 |
+
return {"status": "success", "message": "Product deleted"}
|
app/api/scheduler.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy import select, delete
|
| 4 |
+
from typing import List, Dict, Any, Optional
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
from ..core.dependencies import get_current_active_user
|
| 7 |
+
from ..db.database import get_db
|
| 8 |
+
from ..db.models import Event, User
|
| 9 |
+
from pydantic import BaseModel
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
class RecurringEventCreate(BaseModel):
|
| 14 |
+
title: str
|
| 15 |
+
description: str
|
| 16 |
+
start_time: datetime
|
| 17 |
+
end_time: datetime
|
| 18 |
+
recurrence_pattern: str
|
| 19 |
+
recurrence_end_date: Optional[datetime] = None
|
| 20 |
+
attendees: List[str] = []
|
| 21 |
+
reminder_minutes: int = 30
|
| 22 |
+
|
| 23 |
+
class RecurringEventUpdate(BaseModel):
|
| 24 |
+
title: Optional[str] = None
|
| 25 |
+
description: Optional[str] = None
|
| 26 |
+
start_time: Optional[datetime] = None
|
| 27 |
+
end_time: Optional[datetime] = None
|
| 28 |
+
attendees: Optional[List[str]] = None
|
| 29 |
+
reminder_minutes: Optional[int] = None
|
| 30 |
+
|
| 31 |
+
@router.post("/recurring-events")
|
| 32 |
+
async def create_recurring_event(
|
| 33 |
+
event_data: RecurringEventCreate,
|
| 34 |
+
current_user: User = Depends(get_current_active_user),
|
| 35 |
+
db: AsyncSession = Depends(get_db)
|
| 36 |
+
) -> List[Dict[str, Any]]:
|
| 37 |
+
"""Create a new recurring event"""
|
| 38 |
+
if event_data.recurrence_pattern not in ["daily", "weekly", "monthly", "yearly"]:
|
| 39 |
+
raise HTTPException(
|
| 40 |
+
status_code=400,
|
| 41 |
+
detail="Invalid recurrence pattern. Must be one of: daily, weekly, monthly, yearly"
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
if event_data.start_time >= event_data.end_time:
|
| 45 |
+
raise HTTPException(
|
| 46 |
+
status_code=400,
|
| 47 |
+
detail="End time must be after start time"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
events = []
|
| 51 |
+
current_start = event_data.start_time
|
| 52 |
+
current_end = event_data.end_time
|
| 53 |
+
duration = event_data.end_time - event_data.start_time
|
| 54 |
+
sequence_number = 0
|
| 55 |
+
|
| 56 |
+
while True:
|
| 57 |
+
if event_data.recurrence_end_date and current_start > event_data.recurrence_end_date:
|
| 58 |
+
break
|
| 59 |
+
|
| 60 |
+
event = Event(
|
| 61 |
+
user_id=current_user.id,
|
| 62 |
+
title=event_data.title,
|
| 63 |
+
description=event_data.description,
|
| 64 |
+
start_time=current_start,
|
| 65 |
+
end_time=current_end,
|
| 66 |
+
attendees=event_data.attendees,
|
| 67 |
+
reminder_minutes=event_data.reminder_minutes,
|
| 68 |
+
is_recurring=True,
|
| 69 |
+
recurrence_pattern=event_data.recurrence_pattern,
|
| 70 |
+
sequence_number=sequence_number,
|
| 71 |
+
status="scheduled"
|
| 72 |
+
)
|
| 73 |
+
db.add(event)
|
| 74 |
+
events.append(event)
|
| 75 |
+
|
| 76 |
+
# Calculate next occurrence
|
| 77 |
+
sequence_number += 1
|
| 78 |
+
if event_data.recurrence_pattern == "daily":
|
| 79 |
+
current_start += timedelta(days=1)
|
| 80 |
+
elif event_data.recurrence_pattern == "weekly":
|
| 81 |
+
current_start += timedelta(weeks=1)
|
| 82 |
+
elif event_data.recurrence_pattern == "monthly":
|
| 83 |
+
# Add one month (approximately)
|
| 84 |
+
if current_start.month == 12:
|
| 85 |
+
current_start = current_start.replace(year=current_start.year + 1, month=1)
|
| 86 |
+
else:
|
| 87 |
+
current_start = current_start.replace(month=current_start.month + 1)
|
| 88 |
+
elif event_data.recurrence_pattern == "yearly":
|
| 89 |
+
current_start = current_start.replace(year=current_start.year + 1)
|
| 90 |
+
|
| 91 |
+
current_end = current_start + duration
|
| 92 |
+
|
| 93 |
+
await db.commit()
|
| 94 |
+
|
| 95 |
+
# Refresh all events to get their IDs
|
| 96 |
+
for event in events:
|
| 97 |
+
await db.refresh(event)
|
| 98 |
+
|
| 99 |
+
return events
|
| 100 |
+
|
| 101 |
+
@router.put("/recurring-events/{event_id}")
|
| 102 |
+
async def update_recurring_event(
|
| 103 |
+
event_id: int,
|
| 104 |
+
event_update: RecurringEventUpdate,
|
| 105 |
+
update_future: bool = True,
|
| 106 |
+
current_user: User = Depends(get_current_active_user),
|
| 107 |
+
db: AsyncSession = Depends(get_db)
|
| 108 |
+
) -> List[Dict[str, Any]]:
|
| 109 |
+
"""Update a recurring event and optionally its future occurrences"""
|
| 110 |
+
update_data = event_update.dict(exclude_unset=True)
|
| 111 |
+
if not update_data:
|
| 112 |
+
raise HTTPException(status_code=400, detail="No update data provided")
|
| 113 |
+
|
| 114 |
+
# Get the original event
|
| 115 |
+
stmt = select(Event).where(
|
| 116 |
+
Event.id == event_id,
|
| 117 |
+
Event.user_id == current_user.id
|
| 118 |
+
)
|
| 119 |
+
result = await db.execute(stmt)
|
| 120 |
+
event = result.scalar_one_or_none()
|
| 121 |
+
|
| 122 |
+
if not event:
|
| 123 |
+
raise HTTPException(
|
| 124 |
+
status_code=404,
|
| 125 |
+
detail="Event not found or you don't have permission to update it"
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
updated_events = [event]
|
| 129 |
+
|
| 130 |
+
# Update future occurrences if requested
|
| 131 |
+
if update_future and event.is_recurring:
|
| 132 |
+
future_stmt = select(Event).where(
|
| 133 |
+
Event.recurrence_group == event.recurrence_group,
|
| 134 |
+
Event.sequence_number > event.sequence_number,
|
| 135 |
+
Event.user_id == current_user.id
|
| 136 |
+
)
|
| 137 |
+
future_result = await db.execute(future_stmt)
|
| 138 |
+
future_events = future_result.scalars().all()
|
| 139 |
+
|
| 140 |
+
for future_event in future_events:
|
| 141 |
+
for field, value in update_data.items():
|
| 142 |
+
setattr(future_event, field, value)
|
| 143 |
+
updated_events.append(future_event)
|
| 144 |
+
|
| 145 |
+
await db.commit()
|
| 146 |
+
return updated_events
|
| 147 |
+
|
| 148 |
+
@router.delete("/recurring-events/{event_id}")
|
| 149 |
+
async def delete_recurring_event(
|
| 150 |
+
event_id: int,
|
| 151 |
+
delete_future: bool = True,
|
| 152 |
+
current_user: User = Depends(get_current_active_user),
|
| 153 |
+
db: AsyncSession = Depends(get_db)
|
| 154 |
+
) -> Dict[str, bool]:
|
| 155 |
+
"""Delete a recurring event and optionally its future occurrences"""
|
| 156 |
+
stmt = select(Event).where(
|
| 157 |
+
Event.id == event_id,
|
| 158 |
+
Event.user_id == current_user.id
|
| 159 |
+
)
|
| 160 |
+
result = await db.execute(stmt)
|
| 161 |
+
event = result.scalar_one_or_none()
|
| 162 |
+
|
| 163 |
+
if not event:
|
| 164 |
+
raise HTTPException(
|
| 165 |
+
status_code=404,
|
| 166 |
+
detail="Event not found or you don't have permission to delete it"
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
if delete_future and event.is_recurring:
|
| 170 |
+
delete_stmt = delete(Event).where(
|
| 171 |
+
Event.recurrence_group == event.recurrence_group,
|
| 172 |
+
Event.sequence_number >= event.sequence_number,
|
| 173 |
+
Event.user_id == current_user.id
|
| 174 |
+
)
|
| 175 |
+
await db.execute(delete_stmt)
|
| 176 |
+
else:
|
| 177 |
+
await db.delete(event)
|
| 178 |
+
|
| 179 |
+
await db.commit()
|
| 180 |
+
return {"success": True}
|
| 181 |
+
|
| 182 |
+
@router.get("/recurring-events/upcoming")
|
| 183 |
+
async def get_upcoming_recurring_events(
|
| 184 |
+
days: int = 30,
|
| 185 |
+
current_user: User = Depends(get_current_active_user),
|
| 186 |
+
db: AsyncSession = Depends(get_db)
|
| 187 |
+
) -> List[Dict[str, Any]]:
|
| 188 |
+
"""Get upcoming recurring events for the next N days"""
|
| 189 |
+
if days <= 0 or days > 365:
|
| 190 |
+
raise HTTPException(
|
| 191 |
+
status_code=400,
|
| 192 |
+
detail="Days parameter must be between 1 and 365"
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
end_date = datetime.utcnow() + timedelta(days=days)
|
| 196 |
+
stmt = select(Event).where(
|
| 197 |
+
Event.user_id == current_user.id,
|
| 198 |
+
Event.start_time <= end_date,
|
| 199 |
+
Event.is_recurring == True
|
| 200 |
+
).order_by(Event.start_time)
|
| 201 |
+
|
| 202 |
+
result = await db.execute(stmt)
|
| 203 |
+
return result.scalars().all()
|
app/api/users.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, status, Depends
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy import select
|
| 4 |
+
from typing import List, Optional
|
| 5 |
+
from ..db.database import get_db
|
| 6 |
+
from ..db.models import User
|
| 7 |
+
from ..db.schemas import UserCreate, UserInDB
|
| 8 |
+
from ..core.dependencies import get_current_superuser, get_current_active_user
|
| 9 |
+
from ..core.security import get_password_hash
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
@router.get("/me", response_model=UserInDB)
|
| 14 |
+
async def read_user_me(current_user: User = Depends(get_current_active_user)):
|
| 15 |
+
return current_user
|
| 16 |
+
|
| 17 |
+
@router.get("/", response_model=List[UserInDB])
|
| 18 |
+
async def list_users(
|
| 19 |
+
skip: int = 0,
|
| 20 |
+
limit: int = 10,
|
| 21 |
+
current_user: User = Depends(get_current_superuser),
|
| 22 |
+
db: AsyncSession = Depends(get_db)
|
| 23 |
+
) -> List[UserInDB]:
|
| 24 |
+
stmt = select(User).offset(skip).limit(limit)
|
| 25 |
+
result = await db.execute(stmt)
|
| 26 |
+
return result.scalars().all()
|
| 27 |
+
|
| 28 |
+
@router.post("/", response_model=UserInDB)
|
| 29 |
+
async def create_user(
|
| 30 |
+
user: UserCreate,
|
| 31 |
+
current_user: User = Depends(get_current_superuser),
|
| 32 |
+
db: AsyncSession = Depends(get_db)
|
| 33 |
+
) -> UserInDB:
|
| 34 |
+
# Check if email exists
|
| 35 |
+
stmt = select(User).where(User.email == user.email)
|
| 36 |
+
result = await db.execute(stmt)
|
| 37 |
+
if result.scalar_one_or_none():
|
| 38 |
+
raise HTTPException(
|
| 39 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 40 |
+
detail="Email already registered"
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# Create new user
|
| 44 |
+
db_user = User(
|
| 45 |
+
email=user.email,
|
| 46 |
+
username=user.username,
|
| 47 |
+
full_name=user.full_name,
|
| 48 |
+
hashed_password=get_password_hash(user.password),
|
| 49 |
+
is_active=user.is_active,
|
| 50 |
+
is_superuser=user.is_superuser,
|
| 51 |
+
roles=user.roles
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
db.add(db_user)
|
| 55 |
+
await db.commit()
|
| 56 |
+
await db.refresh(db_user)
|
| 57 |
+
return db_user
|
| 58 |
+
|
| 59 |
+
@router.put("/{user_id}", response_model=UserInDB)
|
| 60 |
+
async def update_user(
|
| 61 |
+
user_id: int,
|
| 62 |
+
user_update: UserCreate,
|
| 63 |
+
current_user: User = Depends(get_current_superuser),
|
| 64 |
+
db: AsyncSession = Depends(get_db)
|
| 65 |
+
) -> UserInDB:
|
| 66 |
+
stmt = select(User).where(User.id == user_id)
|
| 67 |
+
result = await db.execute(stmt)
|
| 68 |
+
db_user = result.scalar_one_or_none()
|
| 69 |
+
|
| 70 |
+
if not db_user:
|
| 71 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 72 |
+
|
| 73 |
+
# Update user fields
|
| 74 |
+
update_data = user_update.dict(exclude_unset=True)
|
| 75 |
+
if "password" in update_data:
|
| 76 |
+
update_data["hashed_password"] = get_password_hash(update_data.pop("password"))
|
| 77 |
+
|
| 78 |
+
for field, value in update_data.items():
|
| 79 |
+
setattr(db_user, field, value)
|
| 80 |
+
|
| 81 |
+
await db.commit()
|
| 82 |
+
await db.refresh(db_user)
|
| 83 |
+
return db_user
|
| 84 |
+
|
| 85 |
+
@router.delete("/{user_id}")
|
| 86 |
+
async def delete_user(
|
| 87 |
+
user_id: int,
|
| 88 |
+
current_user: User = Depends(get_current_superuser),
|
| 89 |
+
db: AsyncSession = Depends(get_db)
|
| 90 |
+
):
|
| 91 |
+
stmt = select(User).where(User.id == user_id)
|
| 92 |
+
result = await db.execute(stmt)
|
| 93 |
+
user = result.scalar_one_or_none()
|
| 94 |
+
|
| 95 |
+
if not user:
|
| 96 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 97 |
+
|
| 98 |
+
await db.delete(user)
|
| 99 |
+
await db.commit()
|
| 100 |
+
return {"status": "success", "message": "User deleted"}
|
| 101 |
+
|
| 102 |
+
@router.put("/{user_id}/roles", response_model=UserInDB)
|
| 103 |
+
async def update_user_roles(
|
| 104 |
+
user_id: int,
|
| 105 |
+
roles: List[str],
|
| 106 |
+
current_user: User = Depends(get_current_superuser),
|
| 107 |
+
db: AsyncSession = Depends(get_db)
|
| 108 |
+
) -> UserInDB:
|
| 109 |
+
valid_roles = ["user", "admin", "manager", "support"]
|
| 110 |
+
invalid_roles = [role for role in roles if role not in valid_roles]
|
| 111 |
+
if invalid_roles:
|
| 112 |
+
raise HTTPException(
|
| 113 |
+
status_code=400,
|
| 114 |
+
detail=f"Invalid roles: {', '.join(invalid_roles)}"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
stmt = select(User).where(User.id == user_id)
|
| 118 |
+
result = await db.execute(stmt)
|
| 119 |
+
user = result.scalar_one_or_none()
|
| 120 |
+
|
| 121 |
+
if not user:
|
| 122 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 123 |
+
|
| 124 |
+
user.roles = roles
|
| 125 |
+
await db.commit()
|
| 126 |
+
await db.refresh(user)
|
| 127 |
+
return user
|
app/core/__init__.py
ADDED
|
File without changes
|
app/core/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (173 Bytes). View file
|
|
|
app/core/__pycache__/config.cpython-312.pyc
ADDED
|
Binary file (1.65 kB). View file
|
|
|
app/core/__pycache__/dependencies.cpython-312.pyc
ADDED
|
Binary file (2.6 kB). View file
|
|
|
app/core/__pycache__/security.cpython-312.pyc
ADDED
|
Binary file (1.72 kB). View file
|
|
|
app/core/config.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings
|
| 2 |
+
from typing import ClassVar
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class Settings(BaseSettings):
|
| 6 |
+
API_V1_STR: str = "/api/v1"
|
| 7 |
+
PROJECT_NAME: str = "Admin Dashboard"
|
| 8 |
+
VERSION: str = "1.0.0"
|
| 9 |
+
|
| 10 |
+
# PostgreSQL Database settings
|
| 11 |
+
DATABASE_URL: ClassVar[str] = "postgresql+asyncpg://postgres.juycnkjuzylnbruwaqmp:Lovyelias5584.@aws-0-eu-central-1.pooler.supabase.com:5432/postgres"
|
| 12 |
+
|
| 13 |
+
# JWT Settings
|
| 14 |
+
SECRET_KEY: str = "your-secret-key-here"
|
| 15 |
+
ALGORITHM: str = "HS256"
|
| 16 |
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
| 17 |
+
|
| 18 |
+
# Redis settings
|
| 19 |
+
REDIS_HOST: str = "localhost"
|
| 20 |
+
REDIS_PORT: int = 6379
|
| 21 |
+
|
| 22 |
+
# Email settings
|
| 23 |
+
MAIL_USERNAME: str = "yungdml31@gmail.com"
|
| 24 |
+
MAIL_PASSWORD: str = ""
|
| 25 |
+
MAIL_FROM: str = "admin@angelo.com"
|
| 26 |
+
MAIL_PORT: int = 587
|
| 27 |
+
MAIL_SERVER: str = "smtp.gmail.com"
|
| 28 |
+
|
| 29 |
+
# Frontend URL
|
| 30 |
+
FRONTEND_URL: str = "http://localhost:3000"
|
| 31 |
+
|
| 32 |
+
class Config:
|
| 33 |
+
case_sensitive = True
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
settings = Settings()
|
app/core/dependencies.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Depends, HTTPException, status
|
| 2 |
+
from fastapi.security import OAuth2PasswordBearer
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
from sqlalchemy import select
|
| 5 |
+
from jose import JWTError, jwt
|
| 6 |
+
from ..db.database import get_db
|
| 7 |
+
from ..db.models import User
|
| 8 |
+
from ..core.config import settings
|
| 9 |
+
|
| 10 |
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
|
| 11 |
+
|
| 12 |
+
async def get_current_user(
|
| 13 |
+
token: str = Depends(oauth2_scheme),
|
| 14 |
+
db: AsyncSession = Depends(get_db)
|
| 15 |
+
):
|
| 16 |
+
credentials_exception = HTTPException(
|
| 17 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 18 |
+
detail="Could not validate credentials",
|
| 19 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
| 24 |
+
user_id: str = payload.get("sub")
|
| 25 |
+
if user_id is None:
|
| 26 |
+
raise credentials_exception
|
| 27 |
+
except JWTError:
|
| 28 |
+
raise credentials_exception
|
| 29 |
+
|
| 30 |
+
stmt = select(User).where(User.id == int(user_id))
|
| 31 |
+
result = await db.execute(stmt)
|
| 32 |
+
user = result.scalar_one_or_none()
|
| 33 |
+
|
| 34 |
+
if user is None:
|
| 35 |
+
raise credentials_exception
|
| 36 |
+
return user
|
| 37 |
+
|
| 38 |
+
async def get_current_active_user(
|
| 39 |
+
current_user: User = Depends(get_current_user)
|
| 40 |
+
):
|
| 41 |
+
if not current_user.is_active:
|
| 42 |
+
raise HTTPException(status_code=400, detail="Inactive user")
|
| 43 |
+
return current_user
|
| 44 |
+
|
| 45 |
+
async def get_current_superuser(
|
| 46 |
+
current_user: User = Depends(get_current_user)
|
| 47 |
+
):
|
| 48 |
+
if not current_user.is_superuser:
|
| 49 |
+
raise HTTPException(
|
| 50 |
+
status_code=403, detail="The user doesn't have enough privileges"
|
| 51 |
+
)
|
| 52 |
+
return current_user
|
app/core/security.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta
|
| 2 |
+
from typing import Any, Optional
|
| 3 |
+
from jose import jwt
|
| 4 |
+
from passlib.context import CryptContext
|
| 5 |
+
from .config import settings
|
| 6 |
+
|
| 7 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 8 |
+
|
| 9 |
+
def create_access_token(subject: Any, expires_delta: Optional[timedelta] = None) -> str:
|
| 10 |
+
if expires_delta:
|
| 11 |
+
expire = datetime.utcnow() + expires_delta
|
| 12 |
+
else:
|
| 13 |
+
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 14 |
+
|
| 15 |
+
to_encode = {"exp": expire, "sub": str(subject)}
|
| 16 |
+
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
| 17 |
+
return encoded_jwt
|
| 18 |
+
|
| 19 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 20 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 21 |
+
|
| 22 |
+
def get_password_hash(password: str) -> str:
|
| 23 |
+
return pwd_context.hash(password)
|
app/db/__init__.py
ADDED
|
File without changes
|
app/db/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (171 Bytes). View file
|
|
|
app/db/__pycache__/database.cpython-312.pyc
ADDED
|
Binary file (2.61 kB). View file
|
|
|
app/db/__pycache__/models.cpython-312.pyc
ADDED
|
Binary file (10.8 kB). View file
|
|
|
app/db/__pycache__/schemas.cpython-312.pyc
ADDED
|
Binary file (16.2 kB). View file
|
|
|
app/db/database.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
| 2 |
+
from sqlalchemy.orm import declarative_base
|
| 3 |
+
from ..core.config import settings
|
| 4 |
+
import contextlib
|
| 5 |
+
|
| 6 |
+
# Create async engine for FastAPI
|
| 7 |
+
async_engine = create_async_engine(
|
| 8 |
+
settings.DATABASE_URL,
|
| 9 |
+
echo=True,
|
| 10 |
+
future=True,
|
| 11 |
+
pool_pre_ping=True
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
# Create async session factory
|
| 15 |
+
AsyncSessionLocal = async_sessionmaker(
|
| 16 |
+
bind=async_engine,
|
| 17 |
+
class_=AsyncSession,
|
| 18 |
+
expire_on_commit=False
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
# Create declarative base for models
|
| 22 |
+
Base = declarative_base()
|
| 23 |
+
|
| 24 |
+
# Database dependency for FastAPI routes
|
| 25 |
+
async def get_db():
|
| 26 |
+
async with AsyncSessionLocal() as session:
|
| 27 |
+
try:
|
| 28 |
+
yield session
|
| 29 |
+
finally:
|
| 30 |
+
await session.close()
|
| 31 |
+
|
| 32 |
+
# Database access for background tasks and services
|
| 33 |
+
class Database:
|
| 34 |
+
def __init__(self):
|
| 35 |
+
self._session_factory = AsyncSessionLocal
|
| 36 |
+
|
| 37 |
+
@contextlib.asynccontextmanager
|
| 38 |
+
async def session(self):
|
| 39 |
+
"""Get a database session with automatic commit/rollback"""
|
| 40 |
+
session = self._session_factory()
|
| 41 |
+
try:
|
| 42 |
+
yield session
|
| 43 |
+
await session.commit()
|
| 44 |
+
except:
|
| 45 |
+
await session.rollback()
|
| 46 |
+
raise
|
| 47 |
+
finally:
|
| 48 |
+
await session.close()
|
| 49 |
+
|
| 50 |
+
async def get_session(self):
|
| 51 |
+
"""Get a session for manual management"""
|
| 52 |
+
return self._session_factory()
|
| 53 |
+
|
| 54 |
+
# Create singleton instance for database access
|
| 55 |
+
db = Database()
|
app/db/init_db.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine
|
| 2 |
+
from sqlalchemy.orm import sessionmaker
|
| 3 |
+
from ..core.config import settings
|
| 4 |
+
from ..core.security import get_password_hash
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from .models import Base, User, Product
|
| 7 |
+
import asyncio
|
| 8 |
+
|
| 9 |
+
def init_db():
|
| 10 |
+
# Create synchronous engine for initialization
|
| 11 |
+
engine = create_engine(
|
| 12 |
+
settings.DATABASE_URL.replace("+asyncpg", ""),
|
| 13 |
+
echo=True
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
# Create all tables
|
| 17 |
+
Base.metadata.create_all(bind=engine)
|
| 18 |
+
|
| 19 |
+
# Create session
|
| 20 |
+
SessionLocal = sessionmaker(bind=engine)
|
| 21 |
+
session = SessionLocal()
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
# Create default admin user if not exists
|
| 25 |
+
admin_user = session.query(User).filter_by(email="admin@example.com").first()
|
| 26 |
+
if not admin_user:
|
| 27 |
+
admin_user = User(
|
| 28 |
+
email="admin@example.com",
|
| 29 |
+
username="admin",
|
| 30 |
+
full_name="System Administrator",
|
| 31 |
+
hashed_password=get_password_hash("admin123"), # Change in production
|
| 32 |
+
is_active=True,
|
| 33 |
+
is_superuser=True,
|
| 34 |
+
roles=["admin"],
|
| 35 |
+
created_at=datetime.utcnow()
|
| 36 |
+
)
|
| 37 |
+
session.add(admin_user)
|
| 38 |
+
print("Created default admin user.")
|
| 39 |
+
|
| 40 |
+
# Create default product categories as products
|
| 41 |
+
categories = [
|
| 42 |
+
"Soups & Stews",
|
| 43 |
+
"Rice Dishes",
|
| 44 |
+
"Swallow & Fufu",
|
| 45 |
+
"Snacks & Small Chops",
|
| 46 |
+
"Protein & Meat",
|
| 47 |
+
"Drinks"
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
for category in categories:
|
| 51 |
+
exists = session.query(Product).filter_by(name=category).first()
|
| 52 |
+
if not exists:
|
| 53 |
+
product = Product(
|
| 54 |
+
name=category,
|
| 55 |
+
description=f"Category: {category}",
|
| 56 |
+
price=0.0, # Category products have zero price
|
| 57 |
+
category=category,
|
| 58 |
+
inventory_count=0, # Categories don't have inventory
|
| 59 |
+
seller_id=admin_user.id if admin_user else 1, # Link to admin user
|
| 60 |
+
created_at=datetime.utcnow()
|
| 61 |
+
)
|
| 62 |
+
session.add(product)
|
| 63 |
+
|
| 64 |
+
print("Initialized product categories.")
|
| 65 |
+
|
| 66 |
+
# Commit changes
|
| 67 |
+
session.commit()
|
| 68 |
+
|
| 69 |
+
except Exception as e:
|
| 70 |
+
print(f"Error during initialization: {e}")
|
| 71 |
+
session.rollback()
|
| 72 |
+
raise
|
| 73 |
+
finally:
|
| 74 |
+
session.close()
|
| 75 |
+
|
| 76 |
+
if __name__ == "__main__":
|
| 77 |
+
init_db()
|