diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..08c969c --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,35 @@ +name: Lint + +on: + pull_request: + branches: + - main + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.12 + + - name: Configure Poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - + echo "$HOME/.local/bin" >> $GITHUB_PATH + poetry config virtualenvs.create false + + - name: Install dependencies + run: poetry install -C ./backend + + - name: Run linters + run: | + cd backend + ruff check yet_another_calendar + mypy yet_another_calendar + diff --git a/backend/Dockerfile b/backend/Dockerfile index 22ea63c..f750ca3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11.4-slim-bullseye as prod +FROM python:3.12.5-slim-bullseye as prod RUN pip install poetry==1.8.2 diff --git a/backend/poetry.lock b/backend/poetry.lock index fa0a3a0..05fe58d 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -34,27 +34,14 @@ files = [ ] [package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" -typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.21.0b1)"] trio = ["trio (>=0.26.1)"] -[[package]] -name = "async-timeout" -version = "4.0.3" -description = "Timeout context manager for asyncio programs" -optional = false -python-versions = ">=3.7" -files = [ - {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, - {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, -] - [[package]] name = "beautifulsoup4" version = "4.12.3" @@ -113,8 +100,6 @@ mypy-extensions = ">=0.4.3" packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] @@ -133,17 +118,6 @@ files = [ {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, ] -[[package]] -name = "cfgv" -version = "3.4.0" -description = "Validate configuration and produce human readable error messages." -optional = false -python-versions = ">=3.8" -files = [ - {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, - {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, -] - [[package]] name = "click" version = "8.1.7" @@ -250,23 +224,9 @@ files = [ {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, ] -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - [package.extras] toml = ["tomli"] -[[package]] -name = "distlib" -version = "0.3.8" -description = "Distribution utilities" -optional = false -python-versions = "*" -files = [ - {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, - {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, -] - [[package]] name = "dnspython" version = "2.6.1" @@ -322,20 +282,6 @@ dev = ["environs[tests]", "pre-commit (>=3.5,<4.0)", "tox"] django = ["dj-database-url", "dj-email-url", "django-cache-url"] tests = ["environs[django]", "pytest"] -[[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, -] - -[package.extras] -test = ["pytest (>=6)"] - [[package]] name = "fakeredis" version = "2.24.1" @@ -350,7 +296,6 @@ files = [ [package.dependencies] redis = ">=4" sortedcontainers = ">=2,<3" -typing_extensions = {version = ">=4.7,<5.0", markers = "python_version < \"3.11\""} [package.extras] bf = ["pyprobables (>=0.6,<0.7)"] @@ -425,22 +370,6 @@ uvicorn = {version = ">=0.15.0", extras = ["standard"]} [package.extras] standard = ["uvicorn[standard] (>=0.15.0)"] -[[package]] -name = "filelock" -version = "3.16.1" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.8" -files = [ - {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, - {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] -typing = ["typing-extensions (>=4.12.2)"] - [[package]] name = "h11" version = "0.14.0" @@ -687,20 +616,6 @@ files = [ {file = "hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"}, ] -[[package]] -name = "identify" -version = "2.6.1" -description = "File identification library for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0"}, - {file = "identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98"}, -] - -[package.extras] -license = ["ukkonen"] - [[package]] name = "idna" version = "3.10" @@ -1139,9 +1054,6 @@ files = [ {file = "multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a"}, ] -[package.dependencies] -typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} - [[package]] name = "mypy" version = "1.11.2" @@ -1180,7 +1092,6 @@ files = [ [package.dependencies] mypy-extensions = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = ">=4.6.0" [package.extras] @@ -1200,17 +1111,6 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] -[[package]] -name = "nodeenv" -version = "1.9.1" -description = "Node.js virtual environment builder" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, - {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, -] - [[package]] name = "packaging" version = "24.1" @@ -1363,24 +1263,6 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] -[[package]] -name = "pre-commit" -version = "3.8.0" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -optional = false -python-versions = ">=3.9" -files = [ - {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, - {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, -] - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -virtualenv = ">=20.10.0" - [[package]] name = "pydantic" version = "2.9.2" @@ -1633,11 +1515,9 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=1.5,<2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] @@ -1673,7 +1553,6 @@ files = [ [package.dependencies] pytest = ">=8.3.3" -tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} [package.extras] testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "pytest-mock (>=3.14)"] @@ -1791,7 +1670,6 @@ files = [ ] [package.dependencies] -async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} hiredis = {version = ">=3.0.0", optional = true, markers = "extra == \"hiredis\""} [package.extras] @@ -1911,22 +1789,10 @@ files = [ [package.dependencies] anyio = ">=3.4.0,<5" -typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - [[package]] name = "typer" version = "0.12.5" @@ -2071,7 +1937,6 @@ h11 = ">=0.8" httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""} python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} -typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} @@ -2123,26 +1988,6 @@ files = [ docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] -[[package]] -name = "virtualenv" -version = "20.26.6" -description = "Virtual Python Environment builder" -optional = false -python-versions = ">=3.7" -files = [ - {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, - {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, -] - -[package.dependencies] -distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<5" - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] - [[package]] name = "watchfiles" version = "0.24.0" @@ -2454,5 +2299,5 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" -python-versions = "^3.9" -content-hash = "6e3401167febbc6b4bb3eea661c7462320dedaba8e9f82d37b68d676adcb7ca7" +python-versions = "^3.12" +content-hash = "27e4246a06416d84c5c2cd15275c20134116b7caa70cf565db9632ff52ccbc99" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 13fa8d5..4c67309 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,7 +13,7 @@ maintainers = [ readme = "README.md" [tool.poetry.dependencies] -python = "^3.9" +python = "^3.12" fastapi = "^0.111.0" uvicorn = { version = "^0.30.1", extras = ["standard"] } pydantic = "^2" @@ -37,7 +37,6 @@ fastapi-cache2 = "^0.2.2" pytest = "^8" ruff = "^0.5.0" mypy = "^1.10.1" -pre-commit = "^3.7.1" black = "^24.4.2" pytest-cov = "^5" anyio = "^4" diff --git a/backend/setup.cfg b/backend/setup.cfg deleted file mode 100644 index 3d23b32..0000000 --- a/backend/setup.cfg +++ /dev/null @@ -1,97 +0,0 @@ -[flake8] -# Base flake8 configuration: -# https://flake8.pycqa.org/en/latest/user/configuration.html - - - - -# format = wemake -statistics = False -doctests = True -enable-extensions = G -count = True -max-string-usages = 4 -max-local-variables = 10 -max-line-length = 120 -max-module-members = 10 -max-complexity = 8 -max-expressions = 12 - -# Plugins: -accept-encodings = utf-8 -radon-max-cc = 10 -radon-no-assert = True -radon-show-closures = True - -exclude = - test_* - __init* - prod.py - dev.py - app/errors.py - app/binders.py - -ignore = - # WPS221 Found line with high Jones Complexity: 16 > 14 - WPS221 - # Found walrus operator - WPS332 - # Found a line that starts with a dot - WPS348 - # double quotes - Q000 - # for capability with fast api typing - WPS404 B008 - # doc strings - DAR201 DAR101 D103 - # f-strings are acceptable: - WPS305 - # no security/cryptographic purposes in project: - S311 - DAR201 - # Found wrong metadata variable: __all__ - WPS410 - # Found `__init__.py` module with logic - WPS412 - # First line should be in imperative mood - D401 - # Found nested class: Config - WPS431 - # Found class without a base class: Config - WPS306 - # Missing "Yields" in Docstring: - yield - DAR301 - # Missing docstring in public nested class - D106 - # Found outer scope names shadowing: - WPS442 - # WPS326 Found implicit string concatenation - WPS326 - # Missing docstring in __init__ - D107 - # Found wrong module name - WPS100 - # Found wrong keyword: pass - WPS420 - # Found incorrect order of methods in a class - WPS338 - - WPS115 - # Found upper-case constant in a class: FFF - - WPS110 - # Found wrong variable name: items - - WPS425 - # Found boolean non-keyword argument: False - - D200 - # One-line docstring should fit on one line with quotes - - WPS432 - - -[isort] -include_trailing_comma = true -# Should be: max-line-length - 1 -line_length = 99 diff --git a/backend/yet_another_calendar/settings.py b/backend/yet_another_calendar/settings.py index f6dd4d9..05bacd2 100644 --- a/backend/yet_another_calendar/settings.py +++ b/backend/yet_another_calendar/settings.py @@ -51,6 +51,7 @@ class Settings(BaseSettings): redis_cookie_key: str = "MODEUS_JWT" redis_jwt_time_live: int = 60 * 60 * 12 # 12 hours redis_events_time_live: int = 60 * 60 * 24 * 14 # 2 weeks + redis_prefix: str = 'FastAPI-redis' modeus_username: str = env.str("MODEUS_USERNAME") modeus_password: str = env.str("MODEUS_PASSWORD") @@ -58,6 +59,7 @@ class Settings(BaseSettings): netology_course_name: str = env.str( "NETOLOGY_COURSE_NAME", "Разработка IT-продуктов и информационных систем", ) + netology_url: str = env.str("NETOLOGY_URL", "https://netology.ru") @property def redis_url(self) -> URL: diff --git a/backend/yet_another_calendar/web/api/bulk/__init__.py b/backend/yet_another_calendar/web/api/bulk/__init__.py new file mode 100644 index 0000000..68d7bd3 --- /dev/null +++ b/backend/yet_another_calendar/web/api/bulk/__init__.py @@ -0,0 +1,5 @@ +"""Routes for swagger and redoc.""" + +from .views import router + +__all__ = ["router"] diff --git a/backend/yet_another_calendar/web/api/bulk/integration.py b/backend/yet_another_calendar/web/api/bulk/integration.py new file mode 100644 index 0000000..590ac8e --- /dev/null +++ b/backend/yet_another_calendar/web/api/bulk/integration.py @@ -0,0 +1,69 @@ +import asyncio +import logging + +from fastapi import HTTPException +from fastapi_cache import default_key_builder, FastAPICache +from fastapi_cache.decorator import cache +from starlette import status + +from yet_another_calendar.settings import settings +from ..netology import views as netology_views +from ..modeus import views as modeus_views +from ..modeus import schema as modeus_schema +from ..netology import schema as netology_schema +from . import schema + +logger = logging.getLogger(__name__) + + +async def refresh_events( + body: modeus_schema.ModeusEventsBody, + jwt_token: str, + calendar_id: int, + cookies: netology_schema.NetologyCookies, +) -> schema.RefreshedCalendarResponse: + """Clear events cache.""" + cached_json = await get_cached_calendar(body, jwt_token, calendar_id, cookies) + if isinstance(cached_json, dict): + cached_calendar = schema.CalendarResponse(**cached_json) + if isinstance(cached_json, schema.CalendarResponse): + cached_calendar = cached_json + calendar = await get_calendar(body, jwt_token, calendar_id, cookies) + changed = cached_calendar.get_hash() != calendar.get_hash() + try: + cache_key = default_key_builder(get_cached_calendar, args=(body, jwt_token, calendar_id, cookies), kwargs={}) + coder = FastAPICache.get_coder() + backend = FastAPICache.get_backend() + await backend.set( + key=f"{settings.redis_prefix}:{cache_key}", + value=coder.encode(calendar), + expire=settings.redis_events_time_live) + except Exception as exception: + logger.error(f"Got redis {exception}") + raise HTTPException(detail="Can't refresh redis", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) from None + return schema.RefreshedCalendarResponse( + **{**calendar.model_dump(by_alias=True), "changed": changed}, + ) + +async def get_calendar( + body: modeus_schema.ModeusEventsBody, + jwt_token: str, + calendar_id: int, + cookies: netology_schema.NetologyCookies, +) -> schema.CalendarResponse: + async with asyncio.TaskGroup() as tg: + netology_response = tg.create_task(netology_views.get_calendar(body, calendar_id, cookies)) + modeus_response = tg.create_task(modeus_views.get_calendar(body, jwt_token)) + return schema.CalendarResponse.model_validate( + {"netology": netology_response.result(), "modeus": modeus_response.result()}, + ) + + +@cache(expire=settings.redis_events_time_live) +async def get_cached_calendar( + body: modeus_schema.ModeusEventsBody, + jwt_token: str, + calendar_id: int, + cookies: netology_schema.NetologyCookies, +) -> schema.CalendarResponse: + return await get_calendar(body, jwt_token, calendar_id, cookies) diff --git a/backend/yet_another_calendar/web/api/bulk/schema.py b/backend/yet_another_calendar/web/api/bulk/schema.py new file mode 100644 index 0000000..5187f52 --- /dev/null +++ b/backend/yet_another_calendar/web/api/bulk/schema.py @@ -0,0 +1,24 @@ +import datetime +import hashlib + +from pydantic import BaseModel, Field + +from ..modeus import schema as modeus_schema +from ..netology import schema as netology_schema + + +class BulkResponse(BaseModel): + netology: netology_schema.SerializedEvents + modeus: list[modeus_schema.FullEvent] + + +class CalendarResponse(BulkResponse): + cached_at: datetime.datetime = Field(default_factory=datetime.datetime.now) + + def get_hash(self) -> str: + dump = BulkResponse(**self.model_dump(by_alias=True)).model_dump_json(by_alias=True) + return hashlib.md5(dump.encode()).hexdigest() + + +class RefreshedCalendarResponse(CalendarResponse): + changed: bool diff --git a/backend/yet_another_calendar/web/api/bulk/views.py b/backend/yet_another_calendar/web/api/bulk/views.py new file mode 100644 index 0000000..6aee795 --- /dev/null +++ b/backend/yet_another_calendar/web/api/bulk/views.py @@ -0,0 +1,42 @@ +""" +Modeus API implemented using a controller. +""" +from typing import Annotated + +from fastapi import APIRouter +from fastapi.params import Depends + +from yet_another_calendar.settings import settings +from ..modeus import schema as modeus_schema +from ..netology import schema as netology_schema +from . import integration, schema + +router = APIRouter() + +@router.post("/events/") +async def get_calendar( + body: modeus_schema.ModeusEventsBody, + cookies: Annotated[netology_schema.NetologyCookies, Depends(netology_schema.get_cookies_from_headers)], + jwt_token: Annotated[str, Depends(modeus_schema.get_cookies_from_headers)], + calendar_id: int = settings.netology_default_course_id, +) -> schema.CalendarResponse: + """ + Get events from Netology and Modeus, cached. + """ + + cached_calendar = await integration.get_cached_calendar(body, jwt_token, calendar_id, cookies) + return schema.CalendarResponse.model_validate(cached_calendar) + +@router.post("/refresh_events/") +async def refresh_calendar( + body: modeus_schema.ModeusEventsBody, + cookies: Annotated[netology_schema.NetologyCookies, Depends(netology_schema.get_cookies_from_headers)], + jwt_token: Annotated[str, Depends(modeus_schema.get_cookies_from_headers)], + calendar_id: int = settings.netology_default_course_id, +) -> schema.RefreshedCalendarResponse: + """ + Refresh events in redis. + """ + + + return await integration.refresh_events(body, jwt_token, calendar_id, cookies) \ No newline at end of file diff --git a/backend/yet_another_calendar/web/api/modeus/integration.py b/backend/yet_another_calendar/web/api/modeus/integration.py index 00753ef..bc63b67 100644 --- a/backend/yet_another_calendar/web/api/modeus/integration.py +++ b/backend/yet_another_calendar/web/api/modeus/integration.py @@ -1,5 +1,5 @@ """Modeus API implementation.""" - +import logging import re from secrets import token_hex from typing import Any @@ -17,6 +17,7 @@ FullEvent, FullModeusPersonSearch, SearchPeople, ExtendedPerson, ) +logger = logging.getLogger(__name__) _token_re = re.compile(r"id_token=([a-zA-Z0-9\-_.]+)") _AUTH_URL = "https://auth.modeus.org/oauth2/authorize" @@ -135,7 +136,6 @@ async def post_modeus(__jwt: str, body: Any, url_part: str, timeout: int = 15) - return response.text -@cache(expire=settings.redis_events_time_live) async def get_events( __jwt: str, body: ModeusEventsBody, diff --git a/backend/yet_another_calendar/web/api/modeus/schema.py b/backend/yet_another_calendar/web/api/modeus/schema.py index ddd3ca6..c6de77b 100644 --- a/backend/yet_another_calendar/web/api/modeus/schema.py +++ b/backend/yet_another_calendar/web/api/modeus/schema.py @@ -3,12 +3,13 @@ from typing import Optional, Self from pydantic import BaseModel, Field, computed_field, model_validator, field_validator +from starlette.responses import Response from . import integration from yet_another_calendar.settings import settings -async def get_cookies_from_headers() -> str: +async def get_cookies_from_headers() -> str | Response: return await integration.login(settings.modeus_username, settings.modeus_password) @@ -18,14 +19,14 @@ class ModeusCreds(BaseModel): username: str password: str +class ModeusTimeBody(BaseModel): + time_min: datetime.datetime = Field(alias="timeMin", examples=["2024-09-23T00:00:00+03:00"]) + time_max: datetime.datetime = Field(alias="timeMax", examples=["2024-09-29T23:59:59+03:00"]) # noinspection PyNestedDecorators -class ModeusEventsBody(BaseModel): +class ModeusEventsBody(ModeusTimeBody): """Modeus search events body.""" size: int = Field(default=50) - time_min: datetime.datetime = Field(alias="timeMin", examples=["2024-09-23T00:00:00+03:00"]) - time_max: datetime.datetime = Field(alias="timeMax", - examples=["2024-09-29T23:59:59+03:00"]) attendee_person_id: list[uuid.UUID] = Field(alias="attendeePersonId", default=["d69c87c8-aece-4f39-b6a2-7b467b968211"]) diff --git a/backend/yet_another_calendar/web/api/modeus/views.py b/backend/yet_another_calendar/web/api/modeus/views.py index ae4eafc..944daa7 100644 --- a/backend/yet_another_calendar/web/api/modeus/views.py +++ b/backend/yet_another_calendar/web/api/modeus/views.py @@ -12,33 +12,25 @@ router = APIRouter() -@router.post('/auth') -async def get_modeus_cookies(body: schema.ModeusCreds) -> str: - """ - Auth in Modeus and return cookies. - """ - return await integration.login(body.username, body.password) - - @router.post("/events/") -async def get_modeus_events_blank( +async def get_calendar( body: schema.ModeusEventsBody, jwt_token: Annotated[str, Depends(schema.get_cookies_from_headers)], ) -> list[schema.FullEvent]: """ - Get events from Modeus when no account. + Get events from Modeus. """ return await integration.get_events(jwt_token, body) -@router.get("/search_blank/") -async def search_blank( +@router.get("/search/") +async def search( jwt_token: Annotated[str, Depends(schema.get_cookies_from_headers)], full_name: str = "Комаев Азамат Олегович", ) -> list[schema.ExtendedPerson]: """ - Search people from Modeus when no account. + Search people from Modeus. """ return await integration.get_people( jwt_token, schema.FullModeusPersonSearch.model_validate({"fullName": full_name}), diff --git a/backend/yet_another_calendar/web/api/netology/integration.py b/backend/yet_another_calendar/web/api/netology/integration.py index 5cd76bf..c343f4b 100644 --- a/backend/yet_another_calendar/web/api/netology/integration.py +++ b/backend/yet_another_calendar/web/api/netology/integration.py @@ -1,4 +1,6 @@ """Netology API implementation.""" +import asyncio +from collections import defaultdict from typing import Any import httpx @@ -8,6 +10,7 @@ from . import schema from yet_another_calendar.settings import settings +from ..modeus.schema import ModeusTimeBody async def auth_netology(username: str, password: str, timeout: int = 15) -> schema.NetologyCookies: @@ -55,22 +58,61 @@ async def send_request( return response.json() -async def get_calendar(cookies: schema.NetologyCookies, calendar_id: int) -> dict[str, Any]: - """Get your calendar events.""" - response = await send_request(cookies, request_settings={ - 'method': 'GET', 'url': '/backend/api/user/programs/calendar', - 'params': {'program_ids[]': calendar_id}, - }) - return response - - -async def get_utmn_course(cookies: schema.NetologyCookies) -> schema.NetologyProgram: +async def get_utmn_course(cookies: schema.NetologyCookies) -> schema.NetologyProgramId: """Get utmn course from netology API.""" request_settings = {'method': 'GET', 'url': '/backend/api/user/programs/calendar/filters'} response = await send_request(cookies, request_settings=request_settings) - netology_program = schema.NetologyPrograms(**response).get_utmn_program() + netology_program = schema.CoursesResponse(**response).get_utmn_program() if not netology_program: raise HTTPException(detail=f"Can't find netology program {settings.netology_course_name}", status_code=status.HTTP_404_NOT_FOUND) return netology_program + + +async def get_events_by_id( + cookies: schema.NetologyCookies, + program_id: int, +) -> schema.CalendarResponse: + """Get events by program id .""" + response = await send_request( + cookies, + request_settings={ + 'method': 'GET', + 'url': f'/backend/api/user/programs/{program_id}/schedule', + }) + + return schema.CalendarResponse.model_validate(response) + + +async def get_program_ids( + cookies: schema.NetologyCookies, + calendar_id: int, +) -> set[int]: + """Get program ids by calendar_id.""" + response = await send_request( + cookies, + request_settings={ + 'method': 'GET', + 'url': f'/backend/api/user/professions/{calendar_id}/schedule', + }) + + return schema.ProfessionResponse.model_validate(response).get_lesson_ids() + +async def get_calendar( + cookies: schema.NetologyCookies, + calendar_id: int, + body: ModeusTimeBody, +) -> schema.SerializedEvents: + """Get extended calendar.""" + program_ids = await get_program_ids(cookies, calendar_id) + serialized_events = defaultdict(list) + tasks = [] + async with asyncio.TaskGroup() as tg: + for program_id in program_ids: + tasks.append(tg.create_task(get_events_by_id(cookies, program_id=program_id))) + for task in tasks: + homework_events, webinars_events = task.result().get_serialized_lessons(body) + serialized_events['homework'].extend(homework_events) + serialized_events['webinars'].extend(webinars_events) + return schema.SerializedEvents.model_validate(serialized_events) \ No newline at end of file diff --git a/backend/yet_another_calendar/web/api/netology/schema.py b/backend/yet_another_calendar/web/api/netology/schema.py index 8140103..f0cdbf8 100644 --- a/backend/yet_another_calendar/web/api/netology/schema.py +++ b/backend/yet_another_calendar/web/api/netology/schema.py @@ -1,9 +1,15 @@ -from typing import Optional, Annotated +import datetime +import re +from typing import Optional, Annotated, Any +from urllib.parse import urljoin from fastapi import Header -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, computed_field from yet_another_calendar.settings import settings +from yet_another_calendar.web.api.modeus.schema import ModeusTimeBody + +_DATE_PATTERN = r"\d{2}.\d{2}.\d{2}" class NetologyCreds(BaseModel): @@ -15,40 +21,159 @@ class NetologyCreds(BaseModel): class NetologyCookies(BaseModel): rails_session: str = Field(alias="_netology-on-rails_session") - sg_payment_exist: str - sg_uid: str - remember_user_token: str - http_x_authentication: str - async def get_cookies_from_headers( rails_session: Annotated[str, Header(alias="_netology-on-rails_session")], - sg_payment_exist: Annotated[str, Header()], - sg_uid: Annotated[str, Header()], - remember_user_token: Annotated[str, Header()], - http_x_authentication: Annotated[str, Header()], ) -> NetologyCookies: return NetologyCookies.model_validate({ "_netology-on-rails_session": rails_session, - "sg_payment_exist": sg_payment_exist, - "sg_uid": sg_uid, - "remember_user_token": remember_user_token, - "http_x_authentication": http_x_authentication }, - ) + }) -class NetologyProgram(BaseModel): +class NetologyProgramId(BaseModel): + """Netology program id.""" id: int title: str url_code: str = Field(alias="urlcode") type: str -class NetologyPrograms(BaseModel): - programs: list[NetologyProgram] +class CoursesResponse(BaseModel): + """Program id.""" + programs: list[NetologyProgramId] - def get_utmn_program(self) -> Optional[NetologyProgram]: + def get_utmn_program(self) -> Optional[NetologyProgramId]: for program in self.programs: if settings.netology_course_name in program.title: return program + + +class BaseLesson(BaseModel): + id: int + lesson_id: int + type: str + title: str + + +class LessonWebinar(BaseLesson): + starts_at: Optional[datetime.datetime] = Field(default=None) + ends_at: Optional[datetime.datetime] = Field(default=None) + status: Optional[str] = Field(default=None) + experts: Optional[list[dict[str, Any]]] = Field(default=None) + video_url: Optional[str] = None + webinar_url: Optional[str] = None + + def is_suitable_time(self, time_min: datetime.datetime, time_max: datetime.datetime) -> bool: + """Check if lesson have suitable time""" + if not self.starts_at or time_min > self.starts_at: + return False + if not self.ends_at or self.ends_at > time_max: + return False + return True + + +# noinspection PyNestedDecorators +class LessonTask(BaseLesson): + path: str + + @computed_field # type: ignore + @property + def url(self) -> str: + return urljoin(settings.netology_url, self.path) + + @computed_field # type: ignore + @property + def deadline(self) -> Optional[datetime.datetime]: + match = re.search(_DATE_PATTERN, self.title) + if not match: + return None + date = match.group(0) + return datetime.datetime.strptime(date, "%d.%m.%y").replace(tzinfo=datetime.timezone.utc) + + def is_suitable_time(self, time_min: datetime.datetime, time_max: datetime.datetime) -> bool: + """Check if lesson have suitable time""" + if self.deadline and time_max > self.deadline > time_min: + return True + return False + + +class NetologyProgram(BaseModel): + lesson_items: list[dict[str, Any]] + + +class CalendarResponse(BaseModel): + lessons: list[NetologyProgram] + + @staticmethod + def filter_lessons( + program: NetologyProgram, time_min: datetime.datetime, time_max: datetime.datetime, + ) -> tuple[list[LessonTask], list[LessonWebinar]]: + """Filter lessons by time and status.""" + homework_events, webinars = [], [] + for lesson in program.lesson_items: + if lesson['type'] in ["task", "test"]: + homework = LessonTask(**lesson) + if homework.is_suitable_time(time_min, time_max): + homework_events.append(homework) + continue + if lesson['type'] == "webinar": + webinar = LessonWebinar(**lesson) + if webinar.is_suitable_time(time_min, time_max): + webinars.append(webinar) + continue + return homework_events, webinars + + def get_serialized_lessons(self, body: ModeusTimeBody) -> tuple[list[Any], list[Any]]: + filtered_webinars = [] + filtered_homework = [] + time_min = body.time_min + time_max = body.time_max + for lesson in self.lessons: + homework_events, webinars_events = self.filter_lessons(lesson, time_min, time_max) + filtered_homework.extend(homework_events) + filtered_webinars.extend(webinars_events) + return filtered_homework, filtered_webinars + + +class ExtendedLesson(LessonWebinar): + passed: bool + experts: Optional[list[dict[str, Any]]] = Field(default=None) + + +class ExtendedLessonResponse(BaseModel): + lesson_items: list[ExtendedLesson] + + def exclude_attachment(self) -> list[ExtendedLesson]: + excluded_lessons = [] + for lesson in self.lesson_items: + if lesson.type != 'attachment': + excluded_lessons.append(lesson) + return excluded_lessons + + +class DetailedProgram(BaseModel): + id: int + name: str + start_date: datetime.datetime + finish_date: datetime.datetime + + +class Program(BaseModel): + detailed_program: DetailedProgram = Field(alias='program') + + +class ProfessionResponse(BaseModel): + """Professions modules info from Netology.""" + profession_modules: list[Program] + + def get_lesson_ids(self) -> set[int]: + program_ids = set() + for program in self.profession_modules: + program_ids.add(program.detailed_program.id) + return program_ids + +class SerializedEvents(BaseModel): + """Structure for displaying frontend.""" + homework: list[LessonTask] + webinars: list[LessonWebinar] diff --git a/backend/yet_another_calendar/web/api/netology/views.py b/backend/yet_another_calendar/web/api/netology/views.py index afd71c0..c323845 100644 --- a/backend/yet_another_calendar/web/api/netology/views.py +++ b/backend/yet_another_calendar/web/api/netology/views.py @@ -5,16 +5,16 @@ from fastapi import APIRouter, Depends from yet_another_calendar.settings import settings -from . import integration -from .schema import NetologyCookies, NetologyCreds, NetologyProgram, get_cookies_from_headers +from . import integration, schema +from ..modeus.schema import ModeusTimeBody router = APIRouter() @router.post("/auth") async def get_netology_cookies( - item: NetologyCreds, -) -> NetologyCookies: + item: schema.NetologyCreds, +) -> schema.NetologyCookies: """ Auth in Netology and return cookies. """ @@ -25,22 +25,23 @@ async def get_netology_cookies( return cookies -@router.get('/utmn_course/') +@router.get('/course/') async def get_course( - cookies: NetologyCookies = Depends(get_cookies_from_headers), -) -> NetologyProgram: + cookies: schema.NetologyCookies = Depends(schema.get_cookies_from_headers), +) -> schema.NetologyProgramId: """ - Auth in Netology and return cookies. + Get netology course ID. """ return await integration.get_utmn_course(cookies) -@router.get('/calendar/') +@router.post('/calendar/') async def get_calendar( - program_id: int = settings.netology_default_course_id, - cookies: NetologyCookies = Depends(get_cookies_from_headers), -) -> dict: + body: ModeusTimeBody, + calendar_id: int = settings.netology_default_course_id, + cookies: schema.NetologyCookies = Depends(schema.get_cookies_from_headers), +) -> schema.SerializedEvents: """ - Auth in Netology and return cookies. + Get Netology Calendar by time. """ - return await integration.get_calendar(cookies, program_id) + return await integration.get_calendar(cookies=cookies, calendar_id=calendar_id, body=body) diff --git a/backend/yet_another_calendar/web/api/router.py b/backend/yet_another_calendar/web/api/router.py index 87615ea..ec2dc19 100644 --- a/backend/yet_another_calendar/web/api/router.py +++ b/backend/yet_another_calendar/web/api/router.py @@ -1,10 +1,10 @@ from fastapi.routing import APIRouter -from yet_another_calendar.settings import settings -from yet_another_calendar.web.api import docs, monitoring, netology, modeus +from yet_another_calendar.web.api import docs, monitoring, netology, modeus, bulk api_router = APIRouter() api_router.include_router(monitoring.router) api_router.include_router(docs.router) api_router.include_router(netology.router, prefix="/netology", tags=["netology"]) api_router.include_router(modeus.router, prefix="/modeus", tags=["modeus"]) +api_router.include_router(bulk.router, prefix="/bulk", tags=["bulk"]) diff --git a/backend/yet_another_calendar/web/lifespan.py b/backend/yet_another_calendar/web/lifespan.py index ff1640b..a9c9941 100644 --- a/backend/yet_another_calendar/web/lifespan.py +++ b/backend/yet_another_calendar/web/lifespan.py @@ -25,9 +25,8 @@ async def lifespan_setup( host=settings.redis_host, port=settings.redis_port, encoding='utf-8', - db=0, ) - FastAPICache.init(RedisBackend(redis)) + FastAPICache.init(RedisBackend(redis), prefix=settings.redis_prefix) try: yield diff --git a/docker-compose.yaml b/docker-compose.yaml index 0fabf8a..b91b337 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -23,6 +23,8 @@ services: image: redis:latest hostname: "yet_another_calendar-redis" restart: always + volumes: + - redis_data:/data environment: ALLOW_EMPTY_PASSWORD: "yes" healthcheck: @@ -34,8 +36,13 @@ services: frontend: build: context: ./frontend + target: dev-run container_name: calendar-frontend restart: always + volumes: + - ./frontend/src:/app/src ports: - - "3000:80" + - "3000:3000" +volumes: + redis_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a9bc661..d8712a1 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -8,7 +8,12 @@ COPY . /app RUN npm run build -FROM nginx:1.23-alpine + +FROM node-build AS dev-run +CMD ["npm", "run", "start"] + + +FROM nginx:1.23-alpine as prod-run COPY --from=node-build /app/build /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/frontend/src/components/Header/Header.js b/frontend/src/components/Header/Header.js index 0bca223..ce488fe 100644 --- a/frontend/src/components/Header/Header.js +++ b/frontend/src/components/Header/Header.js @@ -14,6 +14,92 @@ export default function Header() { Войти + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ПнВтСрЧтПтСбВс
+ дедлайны + + 1234567
2 пара 10:15 11:451234567
3 пара 12:00 13:301234567
4 пара 14:00 15:301234567
5 пара 15:45 17:151234567
6 пара 17:30 19:001234567
7 пара 19:10 20:401234567
); } diff --git a/frontend/src/components/Login/ModeusLoginForm.jsx b/frontend/src/components/Login/ModeusLoginForm.jsx index 27d4b55..315032c 100644 --- a/frontend/src/components/Login/ModeusLoginForm.jsx +++ b/frontend/src/components/Login/ModeusLoginForm.jsx @@ -56,9 +56,6 @@ const ModeusLoginForm = () => { - ); diff --git a/frontend/src/index.css b/frontend/src/index.css index fe94545..1990bf6 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,5 +1,7 @@ @import url("https://fonts.googleapis.com/css2?family=Unbounded:wght@200..900&display=swap"); @import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&family=Unbounded:wght@200..900&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap"); + html { height: 100%; } @@ -21,13 +23,14 @@ code { } .wrapper { + width: 100%; -webkit-touch-callout: none; -webkit-user-select: none; -khtml-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; - margin: 20px 120px; + margin-top: 20px; } .header-line { @@ -42,6 +45,7 @@ code { } header .raspisanie { + margin-left: 120px; font-family: "Unbounded", sans-serif; font-weight: 500; font-size: 32px; @@ -62,11 +66,13 @@ header .export-btn { margin-left: 18px; transition: color 0.2s linear; } + header .export-btn:hover { background-color: #4745b5; } header .login-btn { + margin-right: 120px; cursor: pointer; border: none; background-color: #5856d6; @@ -134,6 +140,9 @@ header .login-btn:hover { } .calendar { + position: absolute; + top: 165px; + right: 248px; background-color: #ecedf0; width: 406px; height: 32px; @@ -148,3 +157,55 @@ header .login-btn:hover { .calendar:hover { background-color: #e7e7ea; } + +.raspisanie-table { + width: 100%; + margin-top: 170px; + border-spacing: 1px; +} +.days { + padding-left: 30px; + font-family: "Unbounded", sans-serif; + font-weight: 500; + font-size: 13px; + border: 1px; + border-bottom: 1px dotted #adadad; +} +.vertical { + text-align: center; + border-bottom: 1px dotted #adadad; +} +.vertical-zagolovok { + padding-left: 47px; + padding-top: 7px; + padding-bottom: 7px; + font-family: "Roboto Mono", monospace; + font-weight: 400; + font-size: 12px; + width: 0; + border-bottom: 1px dotted #adadad; +} + +.vertical-zagolovok::first-line { + font-family: "Roboto", sans-serif; + font-weight: 700; + font-size: 13px; +} + +.off-deadline { + margin-top: 7px; + font-family: "Roboto", sans-serif; + font-weight: 400; + font-size: 13px; + color: #7b61ff; + background: none; + padding: 4px 8px; + border-radius: 6px; + border: 1px solid #7b61ff; + transition: color 0.3s linear; +} +.off-deadline:hover { + cursor: pointer; + background-color: #5856d6; + color: #fff; +} diff --git a/frontend/src/index.js b/frontend/src/index.js index f5b3ac3..6830ada 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -11,11 +11,9 @@ import reportWebVitals from "./reportWebVitals"; import LoginRoute from "./routes/LoginRoute"; import CalendarRoute from "./routes/CalendarRoute"; -import "./index.css"; -import ModeusLoginForm from "./components/Login/ModeusLoginForm"; - import Header from "./components/Header/Header"; import "./index.css"; +import ModeusLoginForm from "./components/Login/ModeusLoginForm"; const root = ReactDOM.createRoot(document.getElementById("root")); const router = createBrowserRouter([ @@ -24,6 +22,9 @@ const router = createBrowserRouter([ element: (
+
+ +
), },