Python backend for a Slack's kudos plugin.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'feat/kefinect' into develop

+323 -1
+3
kefi/config/__init__.py
··· 13 13 SLACK_TEAM_ID: str 14 14 RECHARGE_KEFIS_AMOUNT: int = 550 15 15 REDIS_HOST: str = "redis" 16 + PLAZA_PRICE: int = 10 17 + PLAZA_DEFAULT_HOUR: int = 10 18 + PLAZA_DEFAULT_MINUTE: int = 0 16 19 17 20 class Config: 18 21 env_file = ".env"
+1
kefi/migrations/env.py
··· 18 18 from kefi.config import settings 19 19 from kefi.models.core.models import Transaction, User 20 20 from kefi.models.kudos.models import Action 21 + from kefi.models.plazas.models import Attendance, Plaza 21 22 22 23 config.set_main_option("sqlalchemy.url", settings.db_url()) 23 24 target_metadata = SQLModel.metadata
+30
kefi/migrations/versions/180ff139f0b3_plaza_and_user_unique_in_attendance.py
··· 1 + """plaza and user unique in attendance 2 + 3 + Revision ID: 180ff139f0b3 4 + Revises: b7773310aaa7 5 + Create Date: 2022-11-07 19:13:02.097143 6 + 7 + """ 8 + import sqlalchemy as sa 9 + import sqlmodel 10 + from alembic import op 11 + 12 + # revision identifiers, used by Alembic. 13 + revision = "180ff139f0b3" 14 + down_revision = "b7773310aaa7" 15 + branch_labels = None 16 + depends_on = None 17 + 18 + 19 + def upgrade(): 20 + # ### commands auto generated by Alembic - please adjust! ### 21 + op.create_unique_constraint( 22 + "plaza_id_user_id_unique", "attendance", ["plaza_id", "user_id"] 23 + ) 24 + # ### end Alembic commands ### 25 + 26 + 27 + def downgrade(): 28 + # ### commands auto generated by Alembic - please adjust! ### 29 + op.drop_constraint("plaza_id_user_id_unique", "attendance", type_="unique") 30 + # ### end Alembic commands ###
+32
kefi/migrations/versions/b7773310aaa7_updated_transaction.py
··· 1 + """updated transaction 2 + 3 + Revision ID: b7773310aaa7 4 + Revises: f8d0e71fac20 5 + Create Date: 2022-11-07 17:38:07.069101 6 + 7 + """ 8 + import sqlalchemy as sa 9 + import sqlmodel 10 + from alembic import op 11 + 12 + # revision identifiers, used by Alembic. 13 + revision = "b7773310aaa7" 14 + down_revision = "f8d0e71fac20" 15 + branch_labels = None 16 + depends_on = None 17 + 18 + 19 + def upgrade(): 20 + # ### commands auto generated by Alembic - please adjust! ### 21 + op.add_column( 22 + "transaction", sa.Column("attendance_id", sa.Integer(), nullable=True) 23 + ) 24 + op.create_foreign_key(None, "transaction", "attendance", ["attendance_id"], ["id"]) 25 + # ### end Alembic commands ### 26 + 27 + 28 + def downgrade(): 29 + # ### commands auto generated by Alembic - please adjust! ### 30 + op.drop_constraint(None, "transaction", type_="foreignkey") 31 + op.drop_column("transaction", "attendance_id") 32 + # ### end Alembic commands ###
+51
kefi/migrations/versions/f8d0e71fac20_added_plaza_models.py
··· 1 + """added plaza models 2 + 3 + Revision ID: f8d0e71fac20 4 + Revises: d3a04e3f4a1f 5 + Create Date: 2022-11-07 17:22:29.725246 6 + 7 + """ 8 + import sqlalchemy as sa 9 + import sqlmodel 10 + from alembic import op 11 + 12 + # revision identifiers, used by Alembic. 13 + revision = "f8d0e71fac20" 14 + down_revision = "d3a04e3f4a1f" 15 + branch_labels = None 16 + depends_on = None 17 + 18 + 19 + def upgrade(): 20 + # ### commands auto generated by Alembic - please adjust! ### 21 + op.create_table( 22 + "plaza", 23 + sa.Column("id", sa.Integer(), nullable=False), 24 + sa.Column("date", sa.DateTime(), nullable=False), 25 + sa.PrimaryKeyConstraint("id"), 26 + ) 27 + op.create_index(op.f("ix_plaza_date"), "plaza", ["date"], unique=True) 28 + op.create_table( 29 + "attendance", 30 + sa.Column("id", sa.Integer(), nullable=False), 31 + sa.Column("plaza_id", sa.Integer(), nullable=False), 32 + sa.Column("user_id", sa.Integer(), nullable=False), 33 + sa.ForeignKeyConstraint( 34 + ["plaza_id"], 35 + ["plaza.id"], 36 + ), 37 + sa.ForeignKeyConstraint( 38 + ["user_id"], 39 + ["user.id"], 40 + ), 41 + sa.PrimaryKeyConstraint("id"), 42 + ) 43 + # ### end Alembic commands ### 44 + 45 + 46 + def downgrade(): 47 + # ### commands auto generated by Alembic - please adjust! ### 48 + op.drop_table("attendance") 49 + op.drop_index(op.f("ix_plaza_date"), table_name="plaza") 50 + op.drop_table("plaza") 51 + # ### end Alembic commands ###
+6
kefi/models/core/models.py
··· 5 5 6 6 if TYPE_CHECKING: 7 7 from kefi.models.kudos.models import Action 8 + from kefi.models.plazas.models import Attendance, Plaza 8 9 9 10 10 11 class User(SQLModel, table=True): ··· 28 29 sa_relationship_kwargs=dict(foreign_keys="[Transaction.receiver_id]"), 29 30 ) 30 31 is_admin: bool = False 32 + attendances: list["Attendance"] = Relationship(back_populates="user") 31 33 32 34 def get_short_name(self): 33 35 """Gets the short name of the user.""" ··· 51 53 # Action, the reference of the action used to send the transaction 52 54 action_id: int | None = Field(default=None, foreign_key="action.id") 53 55 action: Optional["Action"] = Relationship(back_populates="transactions") 56 + 57 + # Attendance, the reference of the attendance used to send the transaction 58 + attendance_id: int | None = Field(default=None, foreign_key="attendance.id") 59 + attendance: Optional["Attendance"] = Relationship(back_populates="transactions") 54 60 55 61 # Sender user, if not defined, is a system transaction 56 62 sender_id: int | None = Field(default=None, foreign_key="user.id")
kefi/models/plazas/__init__.py

This is a binary file and will not be displayed.

+85
kefi/models/plazas/helpers.py
··· 1 + import datetime 2 + 3 + import pytz 4 + from sqlmodel import Session, select 5 + 6 + from kefi.config import settings 7 + from kefi.models.core.helpers import available_balance 8 + from kefi.models.core.models import Transaction, User 9 + from kefi.models.plazas.models import Attendance, Plaza 10 + 11 + 12 + def next_plaza_appointment() -> datetime.datetime: 13 + """Gets the next plaza date and time appointment.""" 14 + today = datetime.datetime.today() 15 + next_date = (today + datetime.timedelta((4 - today.weekday()) % 7)).date() 16 + default_time = datetime.time( 17 + hour=settings.PLAZA_DEFAULT_HOUR, minute=settings.PLAZA_DEFAULT_MINUTE 18 + ) 19 + return datetime.datetime.combine(date=next_date, time=default_time).astimezone( 20 + pytz.timezone("Europe/Madrid") 21 + ) 22 + 23 + 24 + def get_or_create_current_plaza(session: Session) -> Plaza: 25 + """Gets the current available plaza.""" 26 + now = datetime.datetime.now() 27 + query = select(Plaza).filter(Plaza.date >= now).order_by("date") 28 + results = session.exec(query) 29 + first = results.first() 30 + if first: 31 + return first 32 + plaza = Plaza(date=next_plaza_appointment()) 33 + session.add(plaza) 34 + return plaza 35 + 36 + 37 + def is_attending(user: User, session: Session) -> bool: 38 + """Checks if the user is attending to the current plaza.""" 39 + plaza = get_or_create_current_plaza(session=session) 40 + query = select(Attendance).filter( 41 + Attendance.user == user, Attendance.plaza == plaza 42 + ) 43 + results = session.exec(query) 44 + return results.first() is not None 45 + 46 + 47 + def create_attendance(user: User, session: Session) -> Attendance: 48 + """Creates the transaction that spends the kefis and also creates the attendance 49 + for the next plaza. 50 + """ 51 + plaza = get_or_create_current_plaza(session=session) 52 + balance = available_balance(user=user, session=session) 53 + price = settings.PLAZA_PRICE 54 + if balance < price: 55 + raise ValueError("The user doesn't have enough balance") 56 + query = select(Attendance).filter( 57 + Attendance.user == user, Attendance.plaza == plaza 58 + ) 59 + results = session.exec(query) 60 + if results.first(): 61 + raise ValueError("The user is already in the plaza") 62 + attendance = Attendance(plaza=plaza, user=user) 63 + session.add(attendance) 64 + transaction = Transaction(amount=-price, user=user, attendance=attendance) 65 + session.add(transaction) 66 + return attendance 67 + 68 + 69 + def delete_attendance(user: User, session: Session) -> None: 70 + """Deletes the attendance and the associated transaction.""" 71 + plaza = get_or_create_current_plaza(session=session) 72 + attendance_query = select(Attendance).filter( 73 + Attendance.user == user, Attendance.plaza == plaza 74 + ) 75 + attendance_results = session.exec(attendance_query) 76 + attendance = attendance_results.first() 77 + if not attendance: 78 + raise ValueError("The user is not in the plaza") 79 + session.delete(attendance) 80 + transaction_query = select(Transaction).filter( 81 + Transaction.attendance == attendance, Transaction.user == user 82 + ) 83 + transaction_results = session.exec(transaction_query) 84 + transaction = transaction_results.first() 85 + session.delete(transaction)
+46
kefi/models/plazas/models.py
··· 1 + import datetime 2 + from typing import TYPE_CHECKING 3 + 4 + from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint 5 + 6 + from kefi.models.core.models import User 7 + 8 + if TYPE_CHECKING: 9 + 10 + from kefi.models.core.models import Transaction 11 + 12 + 13 + class Plaza(SQLModel, table=True): 14 + """A plaza is a meeting session that represents a moment scheduled to create a 15 + group call with the different groups of attendees. 16 + """ 17 + 18 + id: int | None = Field(default=None, primary_key=True) 19 + date: datetime.datetime = Field( 20 + unique=True, index=True 21 + ) # When the meetings are going to be created 22 + attendances: list["Attendance"] = Relationship(back_populates="plaza") 23 + 24 + 25 + class Attendance(SQLModel, table=True): 26 + """An attendance is a user who has spent kefis to be part of a meetings session in 27 + the plaza. 28 + """ 29 + 30 + __table_args__ = ( 31 + UniqueConstraint("plaza_id", "user_id", name="plaza_id_user_id_unique"), 32 + ) 33 + id: int | None = Field(default=None, primary_key=True) 34 + # Meeting session that the attendance makes reference 35 + plaza_id: int = Field(foreign_key="plaza.id") 36 + plaza: Plaza = Relationship( 37 + back_populates="attendances", 38 + sa_relationship_kwargs=dict(foreign_keys="[Attendance.plaza_id]"), 39 + ) 40 + # User who is going to attend to the meeting session 41 + user_id: int = Field(foreign_key="user.id") 42 + user: User = Relationship( 43 + back_populates="attendances", 44 + sa_relationship_kwargs=dict(foreign_keys="[Attendance.user_id]"), 45 + ) 46 + transactions: list["Transaction"] = Relationship(back_populates="attendance")
+58
kefi/tests/test_models.py
··· 1 + import datetime 2 + 1 3 from sqlmodel import Session 2 4 3 5 from kefi.config import settings ··· 10 12 from kefi.models.core.models import Transaction, User 11 13 from kefi.models.kudos.helpers import send_action 12 14 from kefi.models.kudos.models import Action 15 + from kefi.models.plazas.helpers import create_attendance, get_or_create_current_plaza 16 + from kefi.models.plazas.models import Attendance, Plaza 13 17 14 18 15 19 def test_available_balance(session: Session): ··· 87 91 available_balance(user=user, session=session) 88 92 == settings.RECHARGE_KEFIS_AMOUNT 89 93 ) 94 + 95 + 96 + def test_get_or_create_current_plaza(session: Session): 97 + first_plaza = get_or_create_current_plaza(session=session) 98 + assert isinstance(first_plaza, Plaza) 99 + second_plaza = get_or_create_current_plaza(session=session) 100 + assert isinstance(second_plaza, Plaza) 101 + assert first_plaza.id == second_plaza.id 102 + 103 + 104 + def test_get_or_create_current_plaza_with_future_and_past(session: Session): 105 + first_plaza = get_or_create_current_plaza(session=session) 106 + session.add(Plaza(date=first_plaza.date + datetime.timedelta(days=3))) 107 + session.add(Plaza(date=datetime.datetime.now() - datetime.timedelta(days=3))) 108 + assert isinstance(first_plaza, Plaza) 109 + second_plaza = get_or_create_current_plaza(session=session) 110 + assert isinstance(second_plaza, Plaza) 111 + assert first_plaza.id == second_plaza.id 112 + 113 + 114 + def test_create_attendance_no_balance(session: Session): 115 + user = User(slack_user_id="user_1") 116 + session.add(user) 117 + try: 118 + create_attendance(user=user, session=session) 119 + exception = False 120 + except ValueError: 121 + exception = True 122 + assert exception 123 + 124 + 125 + def test_create_attendance(session: Session): 126 + user = User(slack_user_id="user_1") 127 + session.add(user) 128 + recharge_wallets(settings.RECHARGE_KEFIS_AMOUNT, session) 129 + initial_balance = available_balance(user=user, session=session) 130 + attendance = create_attendance(user=user, session=session) 131 + assert isinstance(attendance, Attendance) 132 + assert initial_balance - settings.PLAZA_PRICE == available_balance( 133 + user=user, session=session 134 + ) 135 + 136 + 137 + def test_create_attendance_already_existing(session: Session): 138 + user = User(slack_user_id="user_1") 139 + session.add(user) 140 + recharge_wallets(settings.RECHARGE_KEFIS_AMOUNT, session) 141 + create_attendance(user=user, session=session) 142 + try: 143 + create_attendance(user=user, session=session) 144 + exception = False 145 + except ValueError: 146 + exception = True 147 + assert exception
+10 -1
poetry.lock
··· 950 950 six = ">=1.4.0" 951 951 952 952 [[package]] 953 + name = "pytz" 954 + version = "2022.6" 955 + description = "World timezone definitions, modern and historical" 956 + category = "main" 957 + optional = false 958 + python-versions = "*" 959 + 960 + [[package]] 953 961 name = "pyyaml" 954 962 version = "6.0" 955 963 description = "YAML parser and emitter for Python" ··· 1280 1288 [metadata] 1281 1289 lock-version = "1.1" 1282 1290 python-versions = "^3.10" 1283 - content-hash = "03d1f89f3e03ac02036ab4c15f42186818ac2b356a2908e00e503b56717910cd" 1291 + content-hash = "8e47557b60b0276276fafe2e8917adc19d5c353755f7bc00d80424a0fa618ed0" 1284 1292 1285 1293 [metadata.files] 1286 1294 aiodns = [ ··· 1719 1727 python-multipart = [ 1720 1728 {file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"}, 1721 1729 ] 1730 + pytz = [] 1722 1731 pyyaml = [ 1723 1732 {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, 1724 1733 {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
+1
pyproject.toml
··· 21 21 psycopg2-binary = "^2.9.1" 22 22 aiohttp = {version = "^3.8.0", extras = ["speedups"]} 23 23 single-source = "^0.3.0" 24 + pytz = "^2022.6" 24 25 25 26 [tool.poetry.dev-dependencies] 26 27 black = "^22.10.0"