From 04550cc34ed968c1b2499befd78a7941fa5f25f0 Mon Sep 17 00:00:00 2001 From: artemonsh Date: Tue, 23 May 2023 00:21:55 +0300 Subject: [PATCH] change events to candies --- .gitignore | 6 +- alembic.ini | 110 ++++++++++++++++++ delete.py | 23 ++++ pytest.ini | 6 + requirements.txt | 6 + src/alembic/README | 1 + src/alembic/env.py | 88 ++++++++++++++ src/alembic/script.py.mako | 24 ++++ .../versions/9b6ff45d5de5_initial_revision.py | 30 +++++ src/candies/api.py | 38 ++++++ src/candies/models.py | 14 +++ src/candies/repository.py | 84 +++++++++++++ src/candies/schemas.py | 14 +++ src/config.py | 20 ++++ src/conftest.py | 13 +++ src/db.py | 13 ++- src/events/__pycache__/events.cpython-39.pyc | Bin 810 -> 0 bytes src/events/__pycache__/models.cpython-39.pyc | Bin 1026 -> 0 bytes src/events/api.py | 26 ----- src/events/models.py | 17 --- src/events/repository.py | 24 ---- src/main.py | 14 ++- tests/conftest.py | 35 ++++++ tests/test_count.py | 22 ++++ tests/test_main.py | 2 - 25 files changed, 554 insertions(+), 76 deletions(-) create mode 100644 alembic.ini create mode 100644 delete.py create mode 100644 pytest.ini 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/9b6ff45d5de5_initial_revision.py create mode 100644 src/candies/api.py create mode 100644 src/candies/models.py create mode 100644 src/candies/repository.py create mode 100644 src/candies/schemas.py create mode 100644 src/config.py create mode 100644 src/conftest.py delete mode 100644 src/events/__pycache__/events.cpython-39.pyc delete mode 100644 src/events/__pycache__/models.cpython-39.pyc delete mode 100644 src/events/api.py delete mode 100644 src/events/models.py delete mode 100644 src/events/repository.py create mode 100644 tests/test_count.py delete mode 100644 tests/test_main.py diff --git a/.gitignore b/.gitignore index 4f585aa..61edaf4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ /.pytest_cache -/venv \ No newline at end of file +/venv +__pycache__ +.env* +*.txt +.vscode \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..38455bc --- /dev/null +++ b/alembic.ini @@ -0,0 +1,110 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = 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. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# 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 alembic/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 "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# 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 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[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 + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +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/delete.py b/delete.py new file mode 100644 index 0000000..aaf2102 --- /dev/null +++ b/delete.py @@ -0,0 +1,23 @@ + + +# Создаем список +my_list = [1, 2, 3, 4, 5] + +# Получаем итератор для списка +my_iterator = iter(my_list) + +# Перебираем элементы списка с помощью итератора +print(next(my_iterator)) # Выводит: 1 +print(next(my_iterator)) # Выводит: 2 +print(next(my_iterator)) # Выводит: 3 + +# Использование итератора в цикле for +for item in my_iterator: + print(item) # Выводит: 4, 5 + +# Обработка исключения StopIteration при достижении конца итерации +try: + print(next(my_iterator)) # Вызовет исключение StopIteration +except StopIteration: + print("Итерация завершена") + \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2799803 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +# env_override_existing_values = 1 +env_files = + .env-test + +pythonpath = . src \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 568946c..798b78c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,17 @@ +alembic==1.11.1 colorama==0.4.6 exceptiongroup==1.1.1 greenlet==2.0.2 iniconfig==2.0.0 +Mako==1.2.4 +MarkupSafe==2.1.2 packaging==23.1 pluggy==1.0.0 psycopg2==2.9.6 +pydantic==1.10.7 pytest==7.3.1 +pytest-dotenv==0.5.2 +python-dotenv==1.0.0 SQLAlchemy==2.0.13 tomli==2.0.1 typing_extensions==4.5.0 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..bf474d0 --- /dev/null +++ b/src/alembic/env.py @@ -0,0 +1,88 @@ +from logging.config import fileConfig +import sys +from os.path import abspath, dirname + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +sys.path.insert(0, dirname(dirname(dirname(abspath(__file__))))) + +from src.config import settings +from src.db import Base +from src.candies.models import Candies # noqa + +# 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", f"{settings.DB_URL}") + +# 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/src/alembic/script.py.mako b/src/alembic/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/src/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/src/alembic/versions/9b6ff45d5de5_initial_revision.py b/src/alembic/versions/9b6ff45d5de5_initial_revision.py new file mode 100644 index 0000000..9452128 --- /dev/null +++ b/src/alembic/versions/9b6ff45d5de5_initial_revision.py @@ -0,0 +1,30 @@ +"""Initial revision + +Revision ID: 9b6ff45d5de5 +Revises: +Create Date: 2023-05-23 00:09:16.476677 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '9b6ff45d5de5' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table('candies', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=200), nullable=True), + sa.Column('price', sa.Integer(), nullable=True), + sa.Column('state', sa.String(length=20), server_default='full', nullable=True), + sa.Column('kid', sa.String(length=100), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + + +def downgrade() -> None: + op.drop_table('candies') diff --git a/src/candies/api.py b/src/candies/api.py new file mode 100644 index 0000000..38b8eb0 --- /dev/null +++ b/src/candies/api.py @@ -0,0 +1,38 @@ +from dataclasses import asdict +from dataclasses import dataclass +from dataclasses import field +from datetime import datetime +from typing import Optional +from pydantic import parse_obj_as +from sqlalchemy import insert +from db import Session +from candies.models import Candies +from candies.schemas import SCandy + + +@dataclass +class Candy: + id: Optional[int] = field(default=None, compare=False) + title: str = field(default="Событие") + date: Optional[datetime] = None + location: Optional[str] = None + price: int = 0 + currency: str = field(default="RUB") + tickets: int = 0 + is_passed: bool = False + + @classmethod + def from_dict(cls, d): + return Candy(**d) + + @classmethod + def from_orm(cls, candy: Candies): + return parse_obj_as(SCandy, candy).dict() + + def to_dict(self): + return asdict(self) + + def to_dict_wo_id(self) -> dict: + candy_dict = asdict(self) + candy_dict.pop("id") + return candy_dict diff --git a/src/candies/models.py b/src/candies/models.py new file mode 100644 index 0000000..a26f171 --- /dev/null +++ b/src/candies/models.py @@ -0,0 +1,14 @@ +from datetime import datetime +from src.db import Base +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import Boolean, Computed, ForeignKey, Integer, Text, String, TIMESTAMP + + +class Candies(Base): + __tablename__ = "candies" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(200), nullable=True) + price: Mapped[int] = mapped_column(Integer, nullable=True) + state: Mapped[str] = mapped_column(String(20), nullable=True, server_default="full") + kid: Mapped[str] = mapped_column(String(100), nullable=False) diff --git a/src/candies/repository.py b/src/candies/repository.py new file mode 100644 index 0000000..881e89f --- /dev/null +++ b/src/candies/repository.py @@ -0,0 +1,84 @@ +from sqlalchemy import delete, func, insert, literal_column, select, update +from db import Session + +from candies.models import Candies +from candies.api import Candy + + +class CandiesDB: + # @classmethod + # def add(cls, candy: Candy): + # with Session() as session: + # stmt = insert(Candies).values(**candy.to_dict_wo_id()).returning(Candies.id) + # candy = session.execute(stmt) + # session.commit() + # candy = candy.scalar_one() + # return Candy.from_orm(candy) + + @classmethod + def add(cls, candy: Candy): + with Session() as session: + stmt = insert(Candies).values(**candy.to_dict_wo_id()).returning(Candies) + new_candy = session.execute(stmt) + session.commit() + new_candy = new_candy.mappings().one()["Candies"] + return Candy.from_orm(new_candy) + + @classmethod + def get(cls, candy_id: int): + query = select(Candies.__table__.columns).filter_by(id=candy_id) + with Session() as session: + candy = session.execute(query) + session.commit() + candy = candy.mappings().one() + return Candy.from_dict(candy) + + @classmethod + def list(cls, location=None, price=0): + filter_by = {} + if location: + filter_by |= {"location": location} + if price: + filter_by |= {"price": price} + query = select(Candies).filter_by(**filter_by) + with Session() as session: + candies = session.execute(query) + session.commit() + candy = candies.mappings().all() + return Candy.from_dict(candy) + + @classmethod + def count(cls) -> int: + query = select(func.count(Candies.id)).select_from(Candies) + with Session() as session: + candies_count = session.execute(query) + session.commit() + return candies_count.scalar() + + @classmethod + def update(cls, candy_id: int, candy: Candy): + stmt = update(Candies).where(Candies.id == candy_id).values(**candy.to_dict_wo_id()) + with Session() as session: + session.execute(stmt) + session.commit() + + @classmethod + def finish(cls, candy_id: int): + stmt = update(Candies).where(Candies.id == candy_id).values(is_passed=True) + with Session() as session: + session.execute(stmt) + session.commit() + + @classmethod + def delete(cls, candy_id: int): + stmt = delete(Candies).where(Candies.id == candy_id) + with Session() as session: + session.execute(stmt) + session.commit() + + @classmethod + def delete_all(cls): + stmt = delete(Candies) + with Session() as session: + session.execute(stmt) + session.commit() diff --git a/src/candies/schemas.py b/src/candies/schemas.py new file mode 100644 index 0000000..6574b79 --- /dev/null +++ b/src/candies/schemas.py @@ -0,0 +1,14 @@ +from datetime import datetime +from typing import Optional +from pydantic import BaseModel + + +class SCandy(BaseModel): + id: int + title: str + price: int + state: str + kid: str + + class Config: + orm_mode = True diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..734d2a6 --- /dev/null +++ b/src/config.py @@ -0,0 +1,20 @@ +from typing import Literal +from pydantic import BaseSettings + + +class Settings(BaseSettings): + DB_HOST: str + DB_PORT: int + DB_USER: str + DB_PASS: str + DB_NAME: str + + @property + def DB_URL(self): + return f"postgresql+psycopg2://{self.DB_USER}:{self.DB_PASS}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + + class Config: + env_file = ".env" + +settings = Settings() +settings_test = Settings(_env_file=".env-test") diff --git a/src/conftest.py b/src/conftest.py new file mode 100644 index 0000000..673cca3 --- /dev/null +++ b/src/conftest.py @@ -0,0 +1,13 @@ +import os + +import pytest + +from config import Settings + + +os.environ["MODE"] = "TEST" + +@pytest.fixture(scope="session", autouse=True) +def print_db(): + print(Settings.DB_NAME) + print("A".center(60, "=")) \ No newline at end of file diff --git a/src/db.py b/src/db.py index fab416b..b1c7b3e 100644 --- a/src/db.py +++ b/src/db.py @@ -1,7 +1,18 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, DeclarativeBase +from src.config import settings, settings_test +import os -engine = create_engine("postgresql+psycopg2://postgres:postgres@localhost/events") +# print(os.environ.get("MODE")) + +# if settings.MODE == "TEST": +# DB_URL = settings_test.DB_URL +# else: +DB_URL = settings.DB_URL + +# print(f"{settings.MODE=}, {DB_URL}") + +engine = create_engine(DB_URL) Session = sessionmaker(engine) diff --git a/src/events/__pycache__/events.cpython-39.pyc b/src/events/__pycache__/events.cpython-39.pyc deleted file mode 100644 index 8137812333f79c82275aff905a973546e73d38f2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 810 zcmYjO!EO^V5FOj=-AzcLN5mg2hX^=uKnPW|h+8j_K$u9Y&Du)0_3oy&w<2hJO0S4t zNRIrHuW;cP%7t;#R`F<_#$(Tn-*a(rFa%sjf4<6^0{jk}{l#!|gl=CWNRUKBO#~q! zrkd7Fux-qARCB=zd;}Ruegl%PsEB1efh_rpwU7}XSB+K1VU$diwN=}MM zOY(!SnIq@XhFr&vui)qN>!eTCaUWwQ#VFYTfj(WQ8~6+qIY($KWITbFU_ZUU(_mlF zCFU}EnsLuv<+SpVES&PGZp*?UQ_mNsDwR)MRbD7(vw=aq!9)l?zAAL5tQZ*OI@46m z+|x>$Ac08KH&u2{iMVX*x^jZGjmid|mD-v>*(3o1Srb^B@S%cVcFHV8Vs|#%DQ~kwii=t%IWs9&E@Pa8ZVZf6;g&t_89^UQbOsEF;#G^dF>wY73*tu%+& zfBViocoK3PAuzT0}#!2dUEB0KjeGuM) z0|%bLS5B390#1y*Xj@15JoC*sGvCB{mL&ui|MQ9ebP4%woQKOt=Ow<~X9P~TQxQQZ zag6O&j&P}KZLji#Pkj;4!1{g_iik!crm^VJo=9jS`m`@nnu?5O4tYm-z{78Zhv43L zbd5)6WE9_F6*6*k%yR{*6o9sWn0GL8b$F7uEpVL)yR)LH=As@&Iy!8c3i4XVuN%?M z73L&w8wusCeh&*B9oGtGAhkb+uS$n!N|yCZ_r}L3M`z<#Cm#w^^x;~G{(&qI>Tp8c za}-ZK@VU#qTZaY^LiC70%>4rbKJ<8SqjLB4PK(DEsA8FFiui!3)lZn(;eEw_%` z#_F5BWgbs>|JJ3OtK4*zyS2*AEVs?qZ0-*7ySCQgfbby>bzO%?*HEiYhhXME^$I`$?txlR2`^NRiJ${eH~X#k*l()b%4nQQ19dUAs$`pKwg7n94_*CrF2}( zrG&b`NvpEB1S}L>IQ{!$L!23+3x!!WiK+nLofn5W8 z20{am5wt(WOZ1({Cx$Wb_t~50lU6n}nG1mzX3{Pcbc$i#<)o9vWI0U6rxO$dRrj=A z$P5og_;#kB#PQu7XV2NPkJ)YfbTqK7X_rr>hj}>Bt17RGFCZ3A8!4<)` +# faker.seed_instance(101) # random seed +# m = request.node.get_closest_marker('num_cards') +# if m and len(m.args) > 0: +# num_cards = m.args[0] +# for _ in range(num_cards): +# db.add_card(Card(summary=faker.sentence(), +# owner=faker.first_name())) +# return db diff --git a/tests/test_count.py b/tests/test_count.py new file mode 100644 index 0000000..5e3ef4d --- /dev/null +++ b/tests/test_count.py @@ -0,0 +1,22 @@ +# """ +# Test Cases +# * `count` from an empty database +# * `count` with one item +# * `count` with more than one item +# """ +# import pytest + + +# def test_count_no_cards(cards_db): +# assert cards_db.count() == 0 + +# @pytest.mark.num_cards(1) +# def test_count_one_card(cards_db): +# assert cards_db.count() == 1 + +# @pytest.mark.num_cards(3) +# def test_count_three_cards(cards_db): +# assert cards_db.count() == 3 + +def test_abc(): + assert 2 == 2 diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index 4eb9864..0000000 --- a/tests/test_main.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_sum(): - assert 1 + 2 == 3 \ No newline at end of file