From 490245835db45528cddabf36563a72315fce6730 Mon Sep 17 00:00:00 2001 From: ciomek Date: Sat, 4 Oct 2025 15:42:00 +0200 Subject: [PATCH] Initial commit --- .gitignore | 176 ++++++++++++++++++ .idea/.gitignore | 3 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/survive.iml | 17 ++ Dockerfile | 19 ++ README.md | 30 +++ alembic.ini | 147 +++++++++++++++ config.json | 6 + database.sqlite3 | Bin 0 -> 24576 bytes entrypoint.sh | 8 + main.py | 27 +++ requirements.txt | 51 +++++ src/__init__.py | 9 + src/alembic/README | 1 + src/alembic/env.py | 85 +++++++++ src/alembic/script.py.mako | 29 +++ src/alembic/versions/2607b8f9586f_.py | 48 +++++ src/db/__init__.py | 16 ++ src/maps/__init__.py | 0 src/maps/models.py | 10 + src/maps/router.py | 37 ++++ src/maps/schemas.py | 12 ++ src/maps/tests.py | 85 +++++++++ src/users/__init__.py | 0 src/users/models.py | 31 +++ src/users/router.py | 31 +++ src/users/schemas.py | 6 + src/utils/__init__.py | 0 src/utils/config.py | 35 ++++ src/utils/decorators.py | 88 +++++++++ src/utils/jwt.py | 21 +++ 33 files changed, 1049 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/survive.iml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 config.json create mode 100644 database.sqlite3 create mode 100644 entrypoint.sh create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/alembic/README create mode 100644 src/alembic/env.py create mode 100644 src/alembic/script.py.mako create mode 100644 src/alembic/versions/2607b8f9586f_.py create mode 100644 src/db/__init__.py create mode 100644 src/maps/__init__.py create mode 100644 src/maps/models.py create mode 100644 src/maps/router.py create mode 100644 src/maps/schemas.py create mode 100644 src/maps/tests.py create mode 100644 src/users/__init__.py create mode 100644 src/users/models.py create mode 100644 src/users/router.py create mode 100644 src/users/schemas.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/config.py create mode 100644 src/utils/decorators.py create mode 100644 src/utils/jwt.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90f8fbb --- /dev/null +++ b/.gitignore @@ -0,0 +1,176 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python +# Edit at https://www.toptal.com/developers/gitignore?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# End of https://www.toptal.com/developers/gitignore/api/python \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..9f7199e --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..39fa371 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/survive.iml b/.idea/survive.iml new file mode 100644 index 0000000..a37bcb3 --- /dev/null +++ b/.idea/survive.iml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..feb618b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.9-bookworm + +WORKDIR /app + +ENV TZ=Europe/Warsaw +ENV PORT=8000 + +RUN python -m venv .venv \ + && .venv/bin/pip install --upgrade pip + +COPY ./requirements.txt . +RUN .venv/bin/pip install --no-cache-dir -r requirements.txt +RUN .venv/bin/pip install --no-cache-dir lxml + +COPY . . + +RUN chmod +x /app/entrypoint.sh + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e19abb --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# Quick Setup + +```bash +# Build the Docker image +docker build -t myapp . + +# Run the container +docker run -p 8000:8000 myapp +``` +# Clone the repository +# Create virtual environment +``python3 -m venv .venv`` + +# Activate virtual environment +# Linux/macOS: +```source .venv/bin/activate``` +# Windows (cmd): +```.venv\Scripts\activate``` + +# Upgrade pip +```pip install --upgrade pip``` + +# Install dependencies +```pip install -r requirements.txt``` + +# Run migrations +```alembic upgrade head``` + +# Start the app +```uvicorn src:app --host 0.0.0.0 --port 8000 --log-level info``` diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..64d9b63 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,147 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/src/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = sqlite+pysqlite:///database.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/config.json b/config.json new file mode 100644 index 0000000..4453f9c --- /dev/null +++ b/config.json @@ -0,0 +1,6 @@ +{ + "jwt_secret": "secret", + "db": { + "connection_string": "sqlite:///database.sqlite3" + } +} \ No newline at end of file diff --git a/database.sqlite3 b/database.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..3ba17fe3321b71645a2840565fda32b54844e6a6 GIT binary patch literal 24576 zcmeI&O>f#T7zc2hm#`p#CvAvRrRRbKVw$#g>$*dwW-QxKvoKPa)>9NpkfK7M1Xg;a z$KCdE_HB0DvBw@~Qz8MYCha)%x3FwtC-L)p$iWIHO)HrA)EkZm-h}qpL&iBfB4Ui` zvTCx%B_%tm#)Ze7zqC4g`t5N}+_H4;I}!md9H zX6`id$F91M>ZF$oY}@r_+muUsYkr~CNz|}OO{B6qr83u(nPQ>9=hH>JYDCr4TAX+D z|2@W$7e$xDara(;KLeXrc&1pb@@svu7O(FQI>DLy)gMQ}Fx>c>SX<%7pi0Q{G&h_M z;`^8Pb}MV(wT6AtatxU`+PH!{IzOYeC9YggCgCul6`NJg7D8%gM^c4U-=f2tWV=5P$##AOHafKmY;|fB*!xL?F)-rQQ8!uQ~_4 z*DnwDds!J5_WxUwT!aAu2tWV=5P$##AOHafKmY;|SPF<_NnhRntM~u%PyZ+ofB*y_ z009U<00Izz00bZa0SIidfO`Lr{r?sx7@@~ literal 0 HcmV?d00001 diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..045e602 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,8 @@ +#!/usr/bin/bash + +source /app/.venv/bin/activate + +cd /app/ || exit 1 + +alembic upgrade head +uvicorn src:app --host 0.0.0.0 --port "${PORT:-8000}" --log-level info diff --git a/main.py b/main.py new file mode 100644 index 0000000..7ee03d9 --- /dev/null +++ b/main.py @@ -0,0 +1,27 @@ +import argparse +import uvicorn +from src.users.models import User + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="User management console") + parser.add_argument("--add-user", action="store_true", help="Add a new user to the database") + parser.add_argument("--server", action="store_true", help="Run the FastAPI server") + args = parser.parse_args() + + if args.add_user: + name = input("Enter username: ").strip() + password = input("Enter password: ").strip() + if name and password: + User.add_user(name, password) + else: + print("Username and password cannot be empty.") + + elif args.server: + uvicorn.run( + "src:app", + host="0.0.0.0", + port=8000, + reload=True + ) + else: + print("No action specified. Use --add-user to add a user or --server to run the server.") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..85e7150 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,51 @@ +alembic==1.16.5 +annotated-types==0.7.0 +anyio==4.11.0 +bcrypt==4.0.1 +certifi==2025.8.3 +cffi==2.0.0 +click==8.3.0 +cryptography==46.0.2 +dnspython==2.8.0 +email-validator==2.3.0 +fastapi==0.118.0 +fastapi-cli==0.0.13 +fastapi-cloud-cli==0.3.0 +greenlet==3.2.4 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.6.4 +httpx==0.28.1 +idna==3.10 +Jinja2==3.1.6 +jwt==1.4.0 +Mako==1.3.10 +markdown-it-py==4.0.0 +MarkupSafe==3.0.3 +mdurl==0.1.2 +passlib==1.7.4 +pycparser==2.23 +pydantic==2.11.9 +pydantic_core==2.33.2 +Pygments==2.19.2 +PyJWT==2.10.1 +python-dotenv==1.1.1 +python-multipart==0.0.20 +PyYAML==6.0.3 +rich==14.1.0 +rich-toolkit==0.15.1 +rignore==0.7.0 +sentry-sdk==2.39.0 +shellingham==1.5.4 +sniffio==1.3.1 +SQLAlchemy==2.0.43 +sqlmodel==0.0.25 +starlette==0.48.0 +typer==0.19.2 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +urllib3==2.5.0 +uvicorn==0.37.0 +uvloop==0.21.0 +watchfiles==1.1.0 +websockets==15.0.1 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..1bdd097 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI + +app = FastAPI() + +from src.users.router import users_router +from src.maps.router import maps_router + +app.include_router(users_router, prefix="/users") +app.include_router(maps_router, prefix="/maps") diff --git a/src/alembic/README b/src/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/src/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/src/alembic/env.py b/src/alembic/env.py new file mode 100644 index 0000000..0deedda --- /dev/null +++ b/src/alembic/env.py @@ -0,0 +1,85 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context +from sqlmodel import SQLModel +import sqlmodel + +from src.db import Base +from src.utils.config import get + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +config.set_main_option('sqlalchemy.url', get('db', 'connection_string')) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = SQLModel.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/src/alembic/script.py.mako b/src/alembic/script.py.mako new file mode 100644 index 0000000..697cf67 --- /dev/null +++ b/src/alembic/script.py.mako @@ -0,0 +1,29 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/src/alembic/versions/2607b8f9586f_.py b/src/alembic/versions/2607b8f9586f_.py new file mode 100644 index 0000000..a02657a --- /dev/null +++ b/src/alembic/versions/2607b8f9586f_.py @@ -0,0 +1,48 @@ +"""empty message + +Revision ID: 2607b8f9586f +Revises: +Create Date: 2025-10-04 15:15:16.698698 + +""" +from typing import Sequence, Union + +import sqlmodel +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2607b8f9586f' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('password', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_user_name'), 'user', ['name'], unique=True) + op.create_table('waypoint', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('x', sa.Float(), nullable=False), + sa.Column('y', sa.Float(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('waypoint') + op.drop_index(op.f('ix_user_name'), table_name='user') + op.drop_table('user') + # ### end Alembic commands ### diff --git a/src/db/__init__.py b/src/db/__init__.py new file mode 100644 index 0000000..3457f76 --- /dev/null +++ b/src/db/__init__.py @@ -0,0 +1,16 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base + +from src.utils.config import get + +engine = create_engine(get('db', 'connection_string'), echo=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/src/maps/__init__.py b/src/maps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/maps/models.py b/src/maps/models.py new file mode 100644 index 0000000..df90ef6 --- /dev/null +++ b/src/maps/models.py @@ -0,0 +1,10 @@ +from typing import Optional +from sqlmodel import SQLModel, Field + +class WaypointBase(SQLModel): + name: str + x: float + y: float + +class Waypoint(WaypointBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True, sa_column_kwargs={"autoincrement": True}) diff --git a/src/maps/router.py b/src/maps/router.py new file mode 100644 index 0000000..f113ff1 --- /dev/null +++ b/src/maps/router.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List + +from sqlmodel import select + +from src.db import get_db +from src.maps.models import Waypoint +from src.maps.schemas import WaypointCreate, WaypointResponse +from src.utils.decorators import auth_required + +maps_router = APIRouter() + +@auth_required() +@maps_router.post("/waypoints", response_model=WaypointResponse) +def create_waypoint(waypoint: WaypointCreate, db: Session = Depends(get_db)): + db_wp = Waypoint.model_validate(waypoint) + db.add(db_wp) + db.commit() + db.refresh(db_wp) + return db_wp + + +@auth_required() +@maps_router.get("/waypoints", response_model=List[WaypointResponse]) +def get_waypoints(db: Session = Depends(get_db)): + waypoints = db.execute(select(Waypoint)).scalars().all() + return waypoints + + +@auth_required() +@maps_router.get("/waypoints/{waypoint_id}", response_model=WaypointResponse) +def get_waypoint(waypoint_id: int, db: Session = Depends(get_db)): + wp = db.get(Waypoint, waypoint_id) + if not wp: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waypoint not found") + return wp diff --git a/src/maps/schemas.py b/src/maps/schemas.py new file mode 100644 index 0000000..38cd898 --- /dev/null +++ b/src/maps/schemas.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, ConfigDict + + +class WaypointCreate(BaseModel): + name: str + x: float + y: float + +class WaypointResponse(WaypointCreate): + id: int + + model_config = ConfigDict(from_attributes=True) diff --git a/src/maps/tests.py b/src/maps/tests.py new file mode 100644 index 0000000..e371f54 --- /dev/null +++ b/src/maps/tests.py @@ -0,0 +1,85 @@ +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import StaticPool +from sqlmodel import SQLModel, create_engine, Session +from sqlalchemy.orm import sessionmaker + +from src import app, maps_router +from src.maps.models import Waypoint +from src.db import get_db + +# --- Setup in-memory SQLite --- +TEST_DATABASE_URL = "sqlite:///:memory:" +engine = create_engine( + TEST_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool # <-- crucial for in-memory DB +) +TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + +# --- Override dependency --- +def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + +app.dependency_overrides[get_db] = override_get_db + +# Include router (no need to check prefix) +app.include_router(maps_router, prefix="/maps") + +client = TestClient(app) + +# --- Fixture to create tables --- +@pytest.fixture(scope="module", autouse=True) +def setup_db(): + SQLModel.metadata.create_all(engine) + yield + SQLModel.metadata.drop_all(engine) + +# --- Bypass auth decorator for tests --- +def fake_auth_dependency(): + return lambda: True + +# Monkeypatch your auth_required to do nothing in tests +from src.utils.decorators import auth_required +auth_required = lambda *args, **kwargs: (lambda x: x) + +# --- Tests --- +def test_create_waypoint(): + payload = {"name": "TestPoint", "x": 10.5, "y": 20.5} + response = client.post("/maps/waypoints", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["name"] == payload["name"] + assert data["x"] == payload["x"] + assert data["y"] == payload["y"] + assert "id" in data + +def test_get_waypoints(): + response = client.get("/maps/waypoints") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) >= 1 + +def test_get_waypoint_by_id(): + # Create waypoint first + payload = {"name": "Point1", "x": 1.0, "y": 2.0} + create_resp = client.post("/maps/waypoints", json=payload) + wp_id = create_resp.json()["id"] + + # Fetch by ID + response = client.get(f"/maps/waypoints/{wp_id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == wp_id + assert data["name"] == payload["name"] + +def test_get_waypoint_not_found(): + response = client.get("/maps/waypoints/9999") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == "Waypoint not found" diff --git a/src/users/__init__.py b/src/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/users/models.py b/src/users/models.py new file mode 100644 index 0000000..ca3b914 --- /dev/null +++ b/src/users/models.py @@ -0,0 +1,31 @@ +from sqlmodel import SQLModel, Field, Session +from passlib.context import CryptContext +from typing import Optional + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +class User(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(index=True, unique=True) + password: str + + @staticmethod + def make_password(plain_password: str) -> str: + return pwd_context.hash(plain_password) + + def verify_password(self, plain_password: str) -> bool: + return pwd_context.verify(plain_password, self.password) + + @classmethod + def add_user(cls, session: Session, name: str, password: str): + try: + user = cls(name=name, password=cls.make_password(password)) + session.add(user) + session.commit() + session.refresh(user) + print(f"User '{name}' added successfully.") + return user + except Exception as e: + session.rollback() + print(f"Error: Could not add user '{name}': {e}") diff --git a/src/users/router.py b/src/users/router.py new file mode 100644 index 0000000..9a74341 --- /dev/null +++ b/src/users/router.py @@ -0,0 +1,31 @@ +from fastapi import Request +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from src.db import get_db +from src.users.models import User +from src.users.schemas import LoginRequest +from src.utils.decorators import auth_required +from src.utils.jwt import create_access_token + + +users_router = APIRouter() + + +@users_router.post("/login") +def login(request: LoginRequest, db: Session = Depends(get_db)): + user = db.query(User).filter(User.name == request.name).first() + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + + if not user.verify_password(request.password): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + + access_token = create_access_token(data={"sub": user.id}) + return {"access_token": access_token, "token_type": "bearer"} + +@users_router.get("/info") +@auth_required() +async def get_info(request: Request): + user_name = request.state.user.name + return {"message": f"Hello, {user_name}!"} \ No newline at end of file diff --git a/src/users/schemas.py b/src/users/schemas.py new file mode 100644 index 0000000..073adea --- /dev/null +++ b/src/users/schemas.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class LoginRequest(BaseModel): + name: str + password: str diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/config.py b/src/utils/config.py new file mode 100644 index 0000000..9b7e886 --- /dev/null +++ b/src/utils/config.py @@ -0,0 +1,35 @@ +import json +import os +from enum import Enum +from typing import Any + + +class Environment(Enum): + PRODUCTION = 'production' + DEVELOPMENT = 'development' + + +def get_config_file() -> dict: + with open('config.json', 'r', encoding='utf-8') as f: + return json.load(f) + + +def get(*keys: str) -> Any: + env_var = '__'.join(keys).upper() + if env_var in os.environ: + return os.environ[env_var] + + config = get_config_file() + for key in keys: + if not isinstance(config, dict) or key not in config: + raise KeyError(f"Key path {' -> '.join(keys)} not found in config.") + config = config[key] + + return config + +def get_environment() -> Environment: + value = get('environment') + try: + return Environment(value) + except ValueError: + raise ValueError(f"Invalid environment value '{value}' in config. Must be one of {[e.value for e in Environment]}") diff --git a/src/utils/decorators.py b/src/utils/decorators.py new file mode 100644 index 0000000..59a848f --- /dev/null +++ b/src/utils/decorators.py @@ -0,0 +1,88 @@ +from functools import wraps +from fastapi import Request, HTTPException, status +import jwt + +from src.utils.config import get +from src.users.models import User +from src.db import get_db +from sqlalchemy.orm import Session + +SECRET_KEY = get('jwt_secret') +ALGORITHM = "HS256" + +def auth_required(): + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + request: Request | None = kwargs.get("request") + + # Try to locate Request in args if not found in kwargs + if not request: + for arg in args: + if isinstance(arg, Request): + request = arg + break + + if not request: + raise Exception( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Request object not found" + ) + + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authorization header missing or invalid", + headers={"WWW-Authenticate": "Bearer"} + ) + + token = auth_header.split(" ")[1] + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + user_id = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token payload invalid", + headers={"WWW-Authenticate": "Bearer"} + ) + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired", + headers={"WWW-Authenticate": "Bearer"} + ) + except jwt.InvalidTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + headers={"WWW-Authenticate": "Bearer"} + ) + + # Optional: Fetch the user from the DB and pass it to the route + try: + db: Session = kwargs.get("db") or next(get_db()) + user = db.query(User).filter(User.id == user_id).first() + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Database error" + ) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + headers={"WWW-Authenticate": "Bearer"} + ) + + # Inject user into request.state + request.state.user = user + + return await func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/src/utils/jwt.py b/src/utils/jwt.py new file mode 100644 index 0000000..75c142f --- /dev/null +++ b/src/utils/jwt.py @@ -0,0 +1,21 @@ +from datetime import datetime, timedelta +import jwt + +from src.utils.config import get + +SECRET_KEY = get('jwt_secret') +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 6000 + +def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: + to_encode = data.copy() + + if "sub" in to_encode: + to_encode["sub"] = str(to_encode["sub"]) + + expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)) + to_encode.update({"exp": expire}) + + token = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return token +