commit a763e9c2e0940320b6f1fc77ebc0a7fed55405a0 Author: MH.Dmitrii Date: Sat Feb 28 10:47:42 2026 +0300 first commit diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..29de987 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,12 @@ +name: Build Docker +on: + push: + branches: + - main +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build image + run: docker build -t back:latest . \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e84520a --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# виртуальное окружение +venv/ +.venv/ + +# кэш питона +__pycache__/ +*.pyc +*.pyo +*.pyd +*.pytest_cache + +# IDE и редакторы +.vscode/ +.idea/ + +# OS мусор +.DS_Store +Thumbs.db + +#Подсказки +hint.py + +#env +*.env +#db +*.db +versions/ \ No newline at end of file diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 0000000..a3e3a86 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,8 @@ +services: + backend: + image: back:latest + container_name: gitea + volumes: + - ../server:/home/backend/server + ports: + - "${PORT}:${PORT}" \ No newline at end of file diff --git a/docker/dockerfile b/docker/dockerfile new file mode 100644 index 0000000..d538e2e --- /dev/null +++ b/docker/dockerfile @@ -0,0 +1,7 @@ +FROM python:3.15.0a6-slim +WORKDIR /home/backend +COPY ../server /home/backend +RUN python -m pip install --upgrade pip \ + && python -m pip install -r requirements.txt +RUN chmod +x ./docker/start.sh +ENTRYPOINT ["./docker/start.sh"] \ No newline at end of file diff --git a/docker/start.sh b/docker/start.sh new file mode 100644 index 0000000..802a31e --- /dev/null +++ b/docker/start.sh @@ -0,0 +1,3 @@ +#!/bin/sh +alembic -c server/backend/database/alembic/alembic.ini upgrade head +exec python run.py \ No newline at end of file diff --git a/env_example b/env_example new file mode 100644 index 0000000..2c2c0c2 --- /dev/null +++ b/env_example @@ -0,0 +1 @@ +DIR = "..." \ No newline at end of file diff --git a/makefile b/makefile new file mode 100644 index 0000000..dedc97f --- /dev/null +++ b/makefile @@ -0,0 +1,17 @@ +VENV=source ./.venv/bin/activate; +ALEMBIC=alembic -c ./server/backend/database/alembic/alembic.ini +.PHONY: run run_debug migrate_head migrate_down migrate_history migrate_current migrate +run: + $(VENV) python run.py +run_debug: + $(VENV) python run.py --mode debug +migrate_head: + $(VENV) $(ALEMBIC) upgrade head +migrate_down: + $(VENV) $(ALEMBIC) downgrade -1 +migrate_history: + $(VENV) $(ALEMBIC) history +migrate_current: + $(VENV) $(ALEMBIC) current +migrate: + $(VENV) $(ALEMBIC) revision --autogenerate \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0050915 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +pydantic==2.12.3 +pydantic_settings == 2.12.0 +requests==2.32.5 +dotenv==0.9.9 +fastapi == 0.116.1 +uvicorn == 0.40.0 +sqlalchemy == 2.0.46 +aiosqlite == 0.22.1 +greenlet == 3.3.1 +alembic == 1.18.3 +PyJWT==2.11.0 \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..7131bb7 --- /dev/null +++ b/run.py @@ -0,0 +1,28 @@ +from server.backend.schema.pydantic import settings +import uvicorn +def start(log_level:str): + if __name__ == "__main__": + uvicorn.run( + "server.backend.endpoints.endpoints:api", + host="127.0.0.1", + port=settings.PORT, + reload=True, + log_level=log_level, + access_log=True + ) +import argparse +parser = argparse.ArgumentParser(description="logging") +parser.add_argument( + "--mode", + choices=["debug","info"], + default="info", + help="Режим логирования (по умолчанию: info)" +) +args = parser.parse_args() +match args.mode: + case "debug": + print("Режим:", args.mode) + start(args.mode) + case "info": + print("Режим:", args.mode) + start(args.mode) \ No newline at end of file diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/backend/__init__.py b/server/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/backend/auth/JWT.py b/server/backend/auth/JWT.py new file mode 100644 index 0000000..697d0d0 --- /dev/null +++ b/server/backend/auth/JWT.py @@ -0,0 +1,20 @@ + +import time +from typing import Dict +import jwt as pyjwt +from server.backend.schema.pydantic import settings + +def signJWT(user_info: dict) -> str: + payload = { + "user_id": user_info.id, + "admin":user_info.admin, + "expires": time.time() + settings.ACCESS_TOKEN_EXPIRE_SECONDS + } + token = pyjwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return token +def decodeJWT(token: str) -> dict: + try: + decoded_token = pyjwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return decoded_token if decoded_token["expires"] >= time.time() else None + except: + return {} \ No newline at end of file diff --git a/server/backend/auth/__init__.py b/server/backend/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/backend/database/__init__.py b/server/backend/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/backend/database/alembic/alembic.ini b/server/backend/database/alembic/alembic.ini new file mode 100644 index 0000000..75d99ec --- /dev/null +++ b/server/backend/database/alembic/alembic.ini @@ -0,0 +1,149 @@ +# 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/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 +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).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 tzdata library which can be 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:///server/backend/database/DB/guests.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/server/backend/database/alembic/alembic/README b/server/backend/database/alembic/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/server/backend/database/alembic/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/server/backend/database/alembic/alembic/env.py b/server/backend/database/alembic/alembic/env.py new file mode 100644 index 0000000..44d323a --- /dev/null +++ b/server/backend/database/alembic/alembic/env.py @@ -0,0 +1,78 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context +from server.backend.database.db import Base +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# 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 = Base.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/server/backend/database/alembic/alembic/script.py.mako b/server/backend/database/alembic/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/server/backend/database/alembic/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${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 +${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/server/backend/database/db.py b/server/backend/database/db.py new file mode 100644 index 0000000..1b7d7ed --- /dev/null +++ b/server/backend/database/db.py @@ -0,0 +1,77 @@ +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker +from sqlalchemy import Column, Integer, String, Boolean, select,func, DateTime +import asyncio +from datetime import datetime,timezone + +from pathlib import Path +db_folder = Path(__file__).parent / "DB" +db_folder.mkdir(parents=True, exist_ok=True) +db_path = db_folder / "guests.db" + +async_engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", echo=True) + +class Base(DeclarativeBase): + pass + +AsyncSessionLocal = sessionmaker(async_engine,class_=AsyncSession, expire_on_commit=False) + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + code = Column(String, unique=True, nullable=True) + + name = Column(String, nullable=True) + surname = Column(String, nullable=True) + text_field = Column(String, nullable=True) + food = Column(Boolean) + alco = Column(Boolean) + types_of_alco = Column(String, default="Nothing") + + activated = Column(Boolean) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + last_login = Column(DateTime(timezone=True)) + + admin = Column(Boolean, default=False) + +async def create_user(user_info): + async with AsyncSessionLocal() as session: + user_data = user_info.dict(exclude_unset=True) + new_user = User(**user_data) + session.add(new_user) + await session.commit() + await session.refresh(new_user) + +async def update_user(user_info): + async with AsyncSessionLocal() as session: + result = await session.execute(select(User).where(User.code==user_info.code)) + user = result.scalar_one_or_none() + if user: + update_data = user_info.dict(exclude_unset=True) + for key, value in update_data.items(): + if hasattr(user, key): + setattr(user, key, value) + await session.commit() + return user + +async def list_users(): + async with AsyncSessionLocal() as session: + result = await session.execute(select(User)) + users = result.scalars().all() + if users: + return users + else: + return None + +async def login_user(code): + async with AsyncSessionLocal() as session: + result = await session.execute(select(User).where(User.code == code.code)) + user = result.scalar_one_or_none() + if user: + user.last_login=datetime.now(timezone.utc) + await session.commit() + return user + else: + return None \ No newline at end of file diff --git a/server/backend/endpoints/__init__.py b/server/backend/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/backend/endpoints/endpoints.py b/server/backend/endpoints/endpoints.py new file mode 100644 index 0000000..65c52cf --- /dev/null +++ b/server/backend/endpoints/endpoints.py @@ -0,0 +1,41 @@ +from fastapi import FastAPI, Depends, HTTPException,status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import server.backend.schema.pydantic as pydantic +import server.backend.database.db as db +from server.backend.auth.JWT import signJWT, decodeJWT +api = FastAPI() +security = HTTPBearer() + +async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)): + token = credentials.credentials + user = decodeJWT(token) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + return user +async def check_roles(user=Depends(get_current_user)): + if user.get("admin") != True: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + return user + +@api.post("/update", response_model=pydantic.UserUpdate) +async def update_user(data: pydantic.UserUpdate,user=Depends(get_current_user)): + data = await db.update_user(data) + return data + +@api.post("/create", response_model=pydantic.UserAccess) +async def create_user(user_info: pydantic.UserCreate,user=Depends(check_roles)): + await db.create_user(user_info) + return user_info + +@api.get("/list") +async def list_users(user=Depends(check_roles)): + list_of_users = await db.list_users() + return list_of_users + +@api.post("/auth",response_model=pydantic.Token) +async def auth(code:pydantic.UserAccess): + login = await db.login_user(code) + if login == None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Forbidden") + token = signJWT(login) + return {"access_token": token, "token_type": "bearer"} diff --git a/server/backend/schema/__init__.py b/server/backend/schema/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/backend/schema/pydantic.py b/server/backend/schema/pydantic.py new file mode 100644 index 0000000..d7ae0bc --- /dev/null +++ b/server/backend/schema/pydantic.py @@ -0,0 +1,48 @@ +from pydantic import BaseModel, Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic.types import StringConstraints +from typing_extensions import Annotated +import re + +NameStr = Annotated[ + str, + StringConstraints( + min_length=2, + max_length=50, + pattern=r'^[A-Za-zА-ЯЁа-яё]+$' + ) +] +class UserAccess(BaseModel): + code:str = Field(...,min_length=6,max_length=6, description="Code of the guest") + +class Token(BaseModel): + access_token: str + token_type: str + +class UserOut(BaseModel): + name: NameStr = Field(..., description="Name of the guest") + surname: NameStr = Field(..., description="Surname of the guest") + +class UserCreate(UserAccess): + pass + +class UserUpdate(UserAccess): + name: NameStr = Field(..., description="Name of the guest") + surname: NameStr = Field(..., description="Surname of the guest") + text_field: str = Field("", max_length=500, description="what the guest wants") + activated: bool = Field(False, description="activation of the guest") + food: bool = Field(False, description="Options meat or fish") + alco: bool = Field(False, description="if the guest will drink alco or not") + types_of_alco: str = Field("", description="types of alco") + +class Settings(BaseSettings): + DIR:str + PORT:int + SECRET_KEY:str + ALGORITHM:str + ACCESS_TOKEN_EXPIRE_SECONDS:int + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8" + ) +settings = Settings() \ No newline at end of file diff --git a/server/frontend/css.css b/server/frontend/css.css new file mode 100644 index 0000000..e69de29 diff --git a/server/frontend/index.html b/server/frontend/index.html new file mode 100644 index 0000000..e69de29 diff --git a/server/frontend/js.js b/server/frontend/js.js new file mode 100644 index 0000000..e69de29