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 'develop'

+1825 -383
+55
.fleet/run.json
··· 1 + { 2 + "configurations": [ 3 + { 4 + "type": "docker-compose-up", 5 + "name": "Run docker", 6 + "files": [ 7 + "docker-compose.yml" 8 + ] 9 + }, 10 + { 11 + "name": "Run server", 12 + "type": "python", 13 + "module": "uvicorn", 14 + "arguments": [ 15 + "kefi.main:app", 16 + "--reload" 17 + ] 18 + }, 19 + { 20 + "type": "python-tests", 21 + "name": "Run commands tests", 22 + "testFramework": "pytest", 23 + "target": "kefi/tests/test_commands", 24 + "arguments": [] 25 + }, 26 + { 27 + "type": "python-tests", 28 + "name": "Run models tests", 29 + "testFramework": "pytest", 30 + "target": "kefi/tests/test_models", 31 + "arguments": [] 32 + }, 33 + { 34 + "type": "python-tests", 35 + "name": "Run responses tests", 36 + "testFramework": "pytest", 37 + "target": "kefi/tests/test_responses", 38 + "arguments": [] 39 + }, 40 + { 41 + "type": "python-tests", 42 + "name": "Run slack tests", 43 + "testFramework": "pytest", 44 + "target": "kefi/tests/test_slack", 45 + "arguments": [] 46 + }, 47 + { 48 + "type": "python-tests", 49 + "name": "Run interactivity tests", 50 + "testFramework": "pytest", 51 + "target": "kefi/tests/test_interactivity", 52 + "arguments": [] 53 + } 54 + ] 55 + }
.fleet/settings.json

This is a binary file and will not be displayed.

+2 -1
.github/workflows/action-push-main.yml .github/workflows/action-push.yml
··· 1 - name: Action - Push main 1 + name: Action - Push 2 2 3 3 on: 4 4 push: 5 5 branches: 6 6 - main 7 + - develop 7 8 8 9 jobs: 9 10 lint_and_test:
+1 -1
.pre-commit-config.yaml
··· 16 16 rev: v0.982 17 17 hooks: 18 18 - id: mypy 19 - additional_dependencies: [types-pytz==2022.6.0.1,types-requests==2.28.11] 19 + additional_dependencies: [types-pytz==2022.6.0.1,types-requests==2.28.11, sqlmodel==0.0.8]
+51 -10
README.md
··· 4 4 5 5 --- 6 6 7 + ![Build Status](https://github.com/Dekalabs/kefi-backend/actions/workflows/action-push.yml/badge.svg) 8 + 9 + 7 10 # Welcome to Kefi community! 8 11 9 - ## Run in local 12 + ## Basic setup 10 13 11 14 To be able to run Kefi in you own computer, for development proposes, first, you'll 12 15 need the following software installed and configured: ··· 17 20 18 21 This project uses environment variables to handle the configuration, following the [twelve-factor config recommendation](https://12factor.net/config), and we recommend to use [direnv](https://direnv.net/) to handle project's local environment variables. 19 22 20 - ### 1. Clone the repository 23 + ### Clone the repository 21 24 22 25 First, just clone this repository in your machine. 23 26 24 - ### 2. Create environment variables 27 + ### Create environment variables 25 28 26 29 Assuming you are using [direnv](https://direnv.net/), on the root of the project, creates a file named `.envrc`: 27 30 31 + ```bash 28 32 $ echo "dotenv" > .envrc 33 + ``` 29 34 30 35 Then create an `.env` file with the environment variables. An example: 31 36 37 + ``` 32 38 # PostgreSQL 33 39 # ------------------------------------------------------------------------------ 34 40 POSTGRES_HOST=localhost ··· 47 53 48 54 # Slack 49 55 # ------------------------------------------------------------------------------ 50 - SLACK_BOT_TOKEN= 51 - SLACK_TEAM_ID= 56 + SLACK_BOT_TOKEN=<put here your bot token> 57 + SLACK_TEAM_ID=<put here your team ID> 52 58 53 59 # General 54 60 # ------------------------------------------------------------------------------ 55 61 KEFI_SETTINGS_MODULE=kefi.config.local 56 - 62 + ``` 57 63 58 - ### 3. Launch the external services 64 + ### Launch the external services 59 65 60 66 To launch the database an Redis server, you can launch them using `docker-compose`: 61 67 68 + ```bash 62 69 $ docker-compose up --build 70 + ``` 63 71 64 - ### 4. Install the dependencies 72 + ### Install the dependencies 65 73 66 74 You can install the dependencies of the project using [poetry](https://python-poetry.org/), 67 75 in a local virtual environment, using the following command: 68 76 77 + ```bash 69 78 $ poetry install 79 + ``` 70 80 71 - ### 5. Run tests 81 + ## Running tests 72 82 73 83 Now, to test that everything is working, you can launch the tests using this command: 74 84 75 - poetry run pytest . 85 + ```bash 86 + $ poetry run pytest . 87 + ``` 88 + 89 + ## Running local server 90 + 91 + ### Database migrations 92 + 93 + First, we have to run the migrations in order to get the last version of the database: 94 + 95 + ```bash 96 + $ poetry run alembic upgrade head 97 + ``` 98 + 99 + ### Run server 100 + 101 + Then, we launch the local server with `uvicorn`: 102 + ```bash 103 + $ poetry run uvicorn kefi.main:app --reload 104 + ``` 105 + 106 + ### Force load Kefis 107 + 108 + ```bash 109 + $ poetry run ./manage.py 110 + ``` 111 + 112 + ```python 113 + from kefi.models.helpers import reset_wallets 114 + reset_wallets(session) 115 + session.commit() 116 + ```
+1 -1
docker-compose.yml
··· 9 9 services: 10 10 11 11 postgres: 12 - image: registry.dekaside.com/library/postgres:13 12 + image: dekalabs/postgres:13 13 13 volumes: 14 14 - postgres_data:/var/lib/postgresql/data 15 15 - postgres_data_backups:/backups
+5
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 19 + PLAZA_SIZE: int = 4 20 + LOCALE: str = "es_ES" 16 21 17 22 class Config: 18 23 env_file = ".env"
+21
kefi/constants.py
··· 5 5 CONGRATS: str = "congrats" 6 6 HIGH_FIVE: str = "highfive" 7 7 REWARD: str = "reward" 8 + 9 + 10 + class InteractionType: 11 + SHORTCUT: str = "shortcut" 12 + BLOCK_ACTIONS: str = "block_actions" 13 + VIEW_SUBMISSION: str = "view_submission" 14 + 15 + 16 + class ViewType: 17 + JOIN_MEET: str = "meet_join" 18 + LEAVE_MEET: str = "meet_leave" 19 + 20 + 21 + class Actions: 22 + LEAVE_MEET: str = "meet_leave" 23 + SHOW_MEETS_MODAL: str = "show_meets_modal" 24 + 25 + 26 + class EventBodyType: 27 + URL_VERIFICATION: str = "url_verification" 28 + EVENT_CALLBACK: str = "event_callback"
+18
kefi/dependencies.py
··· 1 1 from fastapi import Form 2 + from pydantic import BaseModel 2 3 from sqlmodel import Session 3 4 4 5 from kefi.models.database import engine 6 + 7 + 8 + class Event(BaseModel): 9 + type: str 10 + user: str 11 + 12 + 13 + class EventBody(BaseModel): 14 + token: str 15 + challenge: str | None 16 + type: str 17 + event: Event | None 18 + 19 + 20 + class InteractionParams: 21 + def __init__(self, payload: str = Form(...)): 22 + self.payload = payload 5 23 6 24 7 25 class SlashCommandParams:
+13 -2
kefi/main.py
··· 1 + import locale 2 + 1 3 from fastapi import FastAPI 2 4 from sqlmodel import Session 3 5 6 + from kefi.config import settings 7 + from kefi.models.core.helpers import create_users 4 8 from kefi.models.database import engine 5 - from kefi.models.helpers import create_default_actions, create_users 6 - from kefi.routers import commands 9 + from kefi.models.kudos.helpers import create_default_actions 10 + from kefi.routers import commands, events, interactivity 7 11 8 12 app = FastAPI() 9 13 app.include_router(commands.router) 14 + app.include_router(interactivity.router) 15 + app.include_router(events.router) 16 + 17 + try: 18 + locale.setlocale(locale.LC_TIME, settings.LOCALE) 19 + except locale.Error: 20 + ... 10 21 11 22 12 23 @app.on_event("startup")
+2 -1
kefi/migrations/env.py
··· 16 16 from sqlmodel import SQLModel 17 17 18 18 from kefi.config import settings 19 - from kefi.models.database import Action, Transaction, User 19 + from kefi.models.database import Action # noqa 20 + from kefi.models.database import Attendance, Plaza, Transaction, User # noqa 20 21 21 22 config.set_main_option("sqlalchemy.url", settings.db_url()) 22 23 target_metadata = SQLModel.metadata
+28
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 + from alembic import op 9 + 10 + # revision identifiers, used by Alembic. 11 + revision = "180ff139f0b3" 12 + down_revision = "b7773310aaa7" 13 + branch_labels = None 14 + depends_on = None 15 + 16 + 17 + def upgrade(): 18 + # ### commands auto generated by Alembic - please adjust! ### 19 + op.create_unique_constraint( 20 + "plaza_id_user_id_unique", "attendance", ["plaza_id", "user_id"] 21 + ) 22 + # ### end Alembic commands ### 23 + 24 + 25 + def downgrade(): 26 + # ### commands auto generated by Alembic - please adjust! ### 27 + op.drop_constraint("plaza_id_user_id_unique", "attendance", type_="unique") 28 + # ### end Alembic commands ###
-1
kefi/migrations/versions/482891658252_init_models.py
··· 6 6 7 7 """ 8 8 import sqlalchemy as sa 9 - import sqlmodel 10 9 from alembic import op 11 10 12 11 # revision identifiers, used by Alembic.
+31
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 + from alembic import op 10 + 11 + # revision identifiers, used by Alembic. 12 + revision = "b7773310aaa7" 13 + down_revision = "f8d0e71fac20" 14 + branch_labels = None 15 + depends_on = None 16 + 17 + 18 + def upgrade(): 19 + # ### commands auto generated by Alembic - please adjust! ### 20 + op.add_column( 21 + "transaction", sa.Column("attendance_id", sa.Integer(), nullable=True) 22 + ) 23 + op.create_foreign_key(None, "transaction", "attendance", ["attendance_id"], ["id"]) 24 + # ### end Alembic commands ### 25 + 26 + 27 + def downgrade(): 28 + # ### commands auto generated by Alembic - please adjust! ### 29 + op.drop_constraint(None, "transaction", type_="foreignkey") 30 + op.drop_column("transaction", "attendance_id") 31 + # ### end Alembic commands ###
-1
kefi/migrations/versions/c07b81f92729_added_is_admin_field.py
··· 6 6 7 7 """ 8 8 import sqlalchemy as sa 9 - import sqlmodel 10 9 from alembic import op 11 10 12 11 # revision identifiers, used by Alembic.
+95
kefi/migrations/versions/d3a04e3f4a1f_update_migrations.py
··· 1 + """update migrations 2 + 3 + Revision ID: d3a04e3f4a1f 4 + Revises: dc867102b767 5 + Create Date: 2022-11-04 12:55:36.744428 6 + 7 + """ 8 + import sqlalchemy as sa 9 + from alembic import op 10 + 11 + # revision identifiers, used by Alembic. 12 + revision = "d3a04e3f4a1f" 13 + down_revision = "dc867102b767" 14 + branch_labels = None 15 + depends_on = None 16 + 17 + 18 + def upgrade(): 19 + # ### commands auto generated by Alembic - please adjust! ### 20 + op.alter_column( 21 + "action", "header_template", existing_type=sa.VARCHAR(), nullable=False 22 + ) 23 + op.alter_column( 24 + "action", "message_template", existing_type=sa.VARCHAR(), nullable=False 25 + ) 26 + op.alter_column( 27 + "action", "context_template", existing_type=sa.VARCHAR(), nullable=False 28 + ) 29 + op.drop_index("ix_action_amount", table_name="action") 30 + op.drop_index("ix_action_context_template", table_name="action") 31 + op.drop_index("ix_action_header_template", table_name="action") 32 + op.drop_index("ix_action_id", table_name="action") 33 + op.drop_index("ix_action_image", table_name="action") 34 + op.drop_index("ix_action_message_template", table_name="action") 35 + op.drop_index("ix_action_text", table_name="action") 36 + op.drop_index("ix_transaction_action_id", table_name="transaction") 37 + op.drop_index("ix_transaction_amount", table_name="transaction") 38 + op.drop_index("ix_transaction_id", table_name="transaction") 39 + op.drop_index("ix_transaction_message", table_name="transaction") 40 + op.drop_index("ix_transaction_receiver_id", table_name="transaction") 41 + op.drop_index("ix_transaction_sender_id", table_name="transaction") 42 + op.drop_index("ix_transaction_user_id", table_name="transaction") 43 + op.alter_column("user", "is_admin", existing_type=sa.BOOLEAN(), nullable=False) 44 + op.drop_index("ix_user_first_name", table_name="user") 45 + op.drop_index("ix_user_id", table_name="user") 46 + op.drop_index("ix_user_is_admin", table_name="user") 47 + op.drop_index("ix_user_last_name", table_name="user") 48 + op.drop_index("ix_user_slack_username", table_name="user") 49 + # ### end Alembic commands ### 50 + 51 + 52 + def downgrade(): 53 + # ### commands auto generated by Alembic - please adjust! ### 54 + op.create_index("ix_user_slack_username", "user", ["slack_username"], unique=False) 55 + op.create_index("ix_user_last_name", "user", ["last_name"], unique=False) 56 + op.create_index("ix_user_is_admin", "user", ["is_admin"], unique=False) 57 + op.create_index("ix_user_id", "user", ["id"], unique=False) 58 + op.create_index("ix_user_first_name", "user", ["first_name"], unique=False) 59 + op.alter_column("user", "is_admin", existing_type=sa.BOOLEAN(), nullable=True) 60 + op.create_index("ix_transaction_user_id", "transaction", ["user_id"], unique=False) 61 + op.create_index( 62 + "ix_transaction_sender_id", "transaction", ["sender_id"], unique=False 63 + ) 64 + op.create_index( 65 + "ix_transaction_receiver_id", "transaction", ["receiver_id"], unique=False 66 + ) 67 + op.create_index("ix_transaction_message", "transaction", ["message"], unique=False) 68 + op.create_index("ix_transaction_id", "transaction", ["id"], unique=False) 69 + op.create_index("ix_transaction_amount", "transaction", ["amount"], unique=False) 70 + op.create_index( 71 + "ix_transaction_action_id", "transaction", ["action_id"], unique=False 72 + ) 73 + op.create_index("ix_action_text", "action", ["text"], unique=False) 74 + op.create_index( 75 + "ix_action_message_template", "action", ["message_template"], unique=False 76 + ) 77 + op.create_index("ix_action_image", "action", ["image"], unique=False) 78 + op.create_index("ix_action_id", "action", ["id"], unique=False) 79 + op.create_index( 80 + "ix_action_header_template", "action", ["header_template"], unique=False 81 + ) 82 + op.create_index( 83 + "ix_action_context_template", "action", ["context_template"], unique=False 84 + ) 85 + op.create_index("ix_action_amount", "action", ["amount"], unique=False) 86 + op.alter_column( 87 + "action", "context_template", existing_type=sa.VARCHAR(), nullable=True 88 + ) 89 + op.alter_column( 90 + "action", "message_template", existing_type=sa.VARCHAR(), nullable=True 91 + ) 92 + op.alter_column( 93 + "action", "header_template", existing_type=sa.VARCHAR(), nullable=True 94 + ) 95 + # ### end Alembic commands ###
+50
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 + from alembic import op 10 + 11 + # revision identifiers, used by Alembic. 12 + revision = "f8d0e71fac20" 13 + down_revision = "d3a04e3f4a1f" 14 + branch_labels = None 15 + depends_on = None 16 + 17 + 18 + def upgrade(): 19 + # ### commands auto generated by Alembic - please adjust! ### 20 + op.create_table( 21 + "plaza", 22 + sa.Column("id", sa.Integer(), nullable=False), 23 + sa.Column("date", sa.DateTime(), nullable=False), 24 + sa.PrimaryKeyConstraint("id"), 25 + ) 26 + op.create_index(op.f("ix_plaza_date"), "plaza", ["date"], unique=True) 27 + op.create_table( 28 + "attendance", 29 + sa.Column("id", sa.Integer(), nullable=False), 30 + sa.Column("plaza_id", sa.Integer(), nullable=False), 31 + sa.Column("user_id", sa.Integer(), nullable=False), 32 + sa.ForeignKeyConstraint( 33 + ["plaza_id"], 34 + ["plaza.id"], 35 + ), 36 + sa.ForeignKeyConstraint( 37 + ["user_id"], 38 + ["user.id"], 39 + ), 40 + sa.PrimaryKeyConstraint("id"), 41 + ) 42 + # ### end Alembic commands ### 43 + 44 + 45 + def downgrade(): 46 + # ### commands auto generated by Alembic - please adjust! ### 47 + op.drop_table("attendance") 48 + op.drop_index(op.f("ix_plaza_date"), table_name="plaza") 49 + op.drop_table("plaza") 50 + # ### end Alembic commands ###
kefi/models/core/__init__.py

This is a binary file and will not be displayed.

+2
kefi/models/core/exceptions.py
··· 1 + class NotEnoughKefi(Exception): 2 + ...
+162
kefi/models/core/helpers.py
··· 1 + from slack_sdk import WebClient 2 + from sqlmodel import Session, func, or_, select 3 + 4 + from kefi.config import settings 5 + from kefi.dependencies import Event, SlashCommandParams 6 + from kefi.models.database import Transaction, User 7 + from kefi.routers.responses import ResetResponse 8 + from kefi.slack import Slack 9 + 10 + 11 + def get_or_create_from_event(event: Event, session: Session) -> tuple["User", bool]: 12 + """Gets or create the user from the given command.""" 13 + query = select(User).filter(User.slack_user_id == event.user) 14 + user = session.exec(query).one_or_none() 15 + created = not user 16 + if not user: 17 + user = User(slack_user_id=event.user) 18 + session.add(user) 19 + return (user, created) 20 + 21 + 22 + def get_or_create_from_command( 23 + command: SlashCommandParams, session: Session 24 + ) -> tuple["User", bool]: 25 + """Gets or create the user from the given command.""" 26 + query = select(User).filter(User.slack_user_id == command.user_id) 27 + user = session.exec(query).one_or_none() 28 + created = not user 29 + if not user: 30 + user = User(slack_user_id=command.user_id, slack_username=command.user_name) 31 + else: 32 + # Updates the slack username 33 + user.slack_username = command.user_name 34 + session.add(user) 35 + return (user, created) 36 + 37 + 38 + def get_or_create_from_interactivity( 39 + interactivity_payload: dict, session: Session 40 + ) -> tuple["User", bool]: 41 + """Gets or create the user from the given command.""" 42 + query = select(User).filter( 43 + User.slack_user_id == interactivity_payload["user"]["id"] 44 + ) 45 + user = session.exec(query).one_or_none() 46 + created = not user 47 + if not user: 48 + user = User( 49 + slack_user_id=interactivity_payload["user"]["id"], 50 + slack_username=interactivity_payload["user"]["username"], 51 + ) 52 + else: 53 + # Updates the slack username 54 + user.slack_username = interactivity_payload["user"]["username"] 55 + session.add(user) 56 + return (user, created) 57 + 58 + 59 + def available_balance(user: User, session: Session) -> int: 60 + """Obtains the available balance of the user, that means the balance not spend from 61 + the monthly received. 62 + """ 63 + query = select(func.sum(Transaction.amount)).filter( # type: ignore 64 + Transaction.user == user, 65 + or_(Transaction.sender == user, Transaction.sender == None), # type: ignore 66 + ) 67 + return session.exec(query).one() or 0 68 + 69 + 70 + def received_balance(user: User, session: Session) -> int: 71 + """Obtains the received balance of the user, that means the balance sent to the 72 + user. 73 + """ 74 + query = select(func.sum(Transaction.amount)).filter( # type: ignore 75 + Transaction.user == user, Transaction.receiver == user 76 + ) 77 + return session.exec(query).one() or 0 78 + 79 + 80 + def find_user_by_slack_user_id(slack_user_id: str, session: Session) -> User | None: 81 + """Gets the user using the Slack user id.""" 82 + query = select(User).filter(User.slack_user_id == slack_user_id) 83 + return session.exec(query).one_or_none() 84 + 85 + 86 + def find_user_by_slack_username(slack_username: str, session: Session) -> User | None: 87 + """Gets the user using the Slack username.""" 88 + query = select(User).filter(User.slack_username == slack_username) 89 + return session.exec(query).one_or_none() 90 + 91 + 92 + def create_users(session: Session) -> None: 93 + """Creates all the users in the team.""" 94 + # Gets the users 95 + client = WebClient(token=settings.SLACK_BOT_TOKEN) 96 + response = client.users_list(team_id=settings.SLACK_TEAM_ID) 97 + members = response["members"] 98 + while response["response_metadata"]["next_cursor"]: 99 + response = client.users_list(team_id=settings.SLACK_TEAM_ID) 100 + members += response["members"] 101 + members = list( 102 + filter(lambda user: not user["is_bot"] and not user["deleted"], members) 103 + ) 104 + # Create or updates the users 105 + for member in members: 106 + query = select(User).filter(User.slack_user_id == member["id"]) 107 + user = session.exec(query).one_or_none() 108 + if not user: 109 + user = User( 110 + slack_user_id=member["id"], 111 + slack_username=member["name"], 112 + first_name=member["profile"].get("first_name", ""), 113 + last_name=member["profile"].get("last_name", ""), 114 + is_admin=member["is_admin"], 115 + ) 116 + else: 117 + user.slack_username = member["name"] 118 + user.is_admin = member["is_admin"] 119 + user.first_name = member["profile"].get("first_name", "") 120 + user.last_name = member["profile"].get("last_name", "") 121 + session.add(user) 122 + 123 + 124 + def get_all_users(session: Session) -> list["User"]: 125 + """Gets the complete list of users.""" 126 + return session.exec(select(User)).all() 127 + 128 + 129 + def send_admin_amount(receiver: User, amount: int, session: Session) -> "Transaction": 130 + """Creates the transaction needed to send an amount to the receiver.""" 131 + receiver_transaction = Transaction(amount=amount, user=receiver) 132 + session.add(receiver_transaction) 133 + return receiver_transaction 134 + 135 + 136 + def recharge_wallets(amount: int, session: Session) -> None: 137 + """Recharge all the wallet with the given amount.""" 138 + query = select(User) 139 + users = session.exec(query).all() 140 + for user in users: 141 + transaction = Transaction(amount=amount, user=user) 142 + session.add(transaction) 143 + 144 + 145 + def reset_wallets(session: "Session") -> None: 146 + """Reset the wallets of all the users.""" 147 + users = session.exec(select(User)).all() 148 + for user in users: 149 + balance = available_balance(user=user, session=session) 150 + amount = settings.RECHARGE_KEFIS_AMOUNT - balance 151 + transaction = Transaction(amount=amount, user=user) 152 + session.add(transaction) 153 + 154 + 155 + def notify_reset_wallet(session: Session) -> None: 156 + """Sends notifications after the wallet was reset.""" 157 + amount = settings.RECHARGE_KEFIS_AMOUNT 158 + users = session.exec(select(User)).all() 159 + for user in users: 160 + slack = Slack() 161 + response = ResetResponse(amount=amount) 162 + slack.post_message_user(user.slack_user_id, blocks=response.render()["blocks"])
+85 -34
kefi/models/database.py
··· 1 - from typing import List, Optional 1 + import datetime 2 + from typing import Optional 2 3 4 + import pytz 3 5 from sqlalchemy import Column, String 4 - from sqlmodel import Field, Relationship, SQLModel, create_engine 6 + from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint, create_engine 5 7 6 8 from kefi.config import settings 7 9 10 + # Creates the engine from the database url, to allow to create a session with 11 + # the database 8 12 engine = create_engine(settings.db_url()) 9 13 10 14 11 - class User(SQLModel, table=True): # type: ignore 15 + class User(SQLModel, table=True): 12 16 """A slack user.""" 13 17 14 - id: Optional[int] = Field(default=None, primary_key=True) 15 - first_name: Optional[str] = "" 16 - last_name: Optional[str] = "" 18 + id: int | None = Field(default=None, primary_key=True) 19 + first_name: str | None = "" 20 + last_name: str | None = "" 17 21 slack_user_id: str = Field(sa_column=Column(String(100), unique=True, index=True)) 18 - slack_username: Optional[str] = "" 19 - transactions: List["Transaction"] = Relationship( 22 + slack_username: str | None = "" 23 + transactions: list["Transaction"] = Relationship( 20 24 back_populates="user", 21 25 sa_relationship_kwargs=dict(foreign_keys="[Transaction.user_id]"), 22 26 ) 23 - transactions_sent: List["Transaction"] = Relationship( 27 + transactions_sent: list["Transaction"] = Relationship( 24 28 back_populates="sender", 25 29 sa_relationship_kwargs=dict(foreign_keys="[Transaction.sender_id]"), 26 30 ) 27 - transactions_received: List["Transaction"] = Relationship( 31 + transactions_received: list["Transaction"] = Relationship( 28 32 back_populates="receiver", 29 33 sa_relationship_kwargs=dict(foreign_keys="[Transaction.receiver_id]"), 30 34 ) 31 35 is_admin: bool = False 36 + attendances: list["Attendance"] = Relationship(back_populates="user") 32 37 33 38 def get_short_name(self): 34 39 """Gets the short name of the user.""" 35 40 return self.first_name or self.slack_username 36 41 37 42 38 - class Action(SQLModel, table=True): # type: ignore 39 - """Each action a user can perform to give kefis to another user.""" 40 - 41 - id: Optional[int] = Field(default=None, primary_key=True) 42 - keyword: str = Field(sa_column=Column(String(100), unique=True, index=True)) 43 - amount: int 44 - transactions: List["Transaction"] = Relationship(back_populates="action") 45 - # Responses data 46 - header_template: str = "" 47 - message_template: str = "" 48 - context_template: str = "" 49 - image: Optional[str] 50 - text: Optional[str] 51 - 52 - 53 - class Transaction(SQLModel, table=True): # type: ignore 43 + class Transaction(SQLModel, table=True): 54 44 """A transaction is a kefi movement, form the system to a user or from a user to 55 45 another. 56 46 """ 57 47 58 - id: Optional[int] = Field(default=None, primary_key=True) 48 + id: int | None = Field(default=None, primary_key=True) 59 49 amount: int 60 50 user_id: int = Field(foreign_key="user.id") 61 51 user: User = Relationship( 62 52 back_populates="transactions", 63 53 sa_relationship_kwargs=dict(foreign_keys="[Transaction.user_id]"), 64 54 ) 65 - message: Optional[str] = Field(default=None) 55 + message: str | None = Field(default=None) 66 56 67 57 # Action, the reference of the action used to send the transaction 68 - action_id: Optional[int] = Field(default=None, foreign_key="action.id") 69 - action: Optional[Action] = Relationship(back_populates="transactions") 58 + action_id: int | None = Field(default=None, foreign_key="action.id") 59 + action: Optional["Action"] = Relationship(back_populates="transactions") 60 + 61 + # Attendance, the reference of the attendance used to send the transaction 62 + attendance_id: int | None = Field(default=None, foreign_key="attendance.id") 63 + attendance: Optional["Attendance"] = Relationship(back_populates="transactions") 70 64 71 65 # Sender user, if not defined, is a system transaction 72 - sender_id: Optional[int] = Field(default=None, foreign_key="user.id") 73 - sender: Optional[User] = Relationship( 66 + sender_id: int | None = Field(default=None, foreign_key="user.id") 67 + sender: User | None = Relationship( 74 68 back_populates="transactions_sent", 75 69 sa_relationship_kwargs=dict(foreign_keys="[Transaction.sender_id]"), 76 70 ) 77 71 78 72 # Receiver user 79 - receiver_id: Optional[int] = Field(default=None, foreign_key="user.id") 80 - receiver: Optional[User] = Relationship( 73 + receiver_id: int | None = Field(default=None, foreign_key="user.id") 74 + receiver: User | None = Relationship( 81 75 back_populates="transactions_received", 82 76 sa_relationship_kwargs=dict(foreign_keys="[Transaction.receiver_id]"), 83 77 ) 78 + 79 + 80 + class Action(SQLModel, table=True): 81 + """Each action a user can perform to give kefis to another user.""" 82 + 83 + id: int | None = Field(default=None, primary_key=True) 84 + keyword: str = Field(sa_column=Column(String(100), unique=True, index=True)) 85 + amount: int 86 + transactions: list["Transaction"] = Relationship(back_populates="action") 87 + # Responses data 88 + header_template: str = "" 89 + message_template: str = "" 90 + context_template: str = "" 91 + image: str | None 92 + text: str | None 93 + 94 + 95 + class Plaza(SQLModel, table=True): 96 + """A plaza is a meeting session that represents a moment scheduled to create a 97 + group call with the different groups of attendees. 98 + """ 99 + 100 + id: int | None = Field(default=None, primary_key=True) 101 + date: datetime.datetime = Field( 102 + unique=True, index=True 103 + ) # When the meetings are going to be created 104 + attendances: list["Attendance"] = Relationship(back_populates="plaza") 105 + 106 + def local_date(self) -> datetime.datetime: 107 + """Gets the local date, using default Europe/Madrid timezone.""" 108 + return self.date.replace(tzinfo=pytz.utc).astimezone( 109 + pytz.timezone("Europe/Madrid") 110 + ) 111 + 112 + 113 + class Attendance(SQLModel, table=True): 114 + """An attendance is a user who has spent kefis to be part of a meetings session in 115 + the plaza. 116 + """ 117 + 118 + __table_args__ = ( 119 + UniqueConstraint("plaza_id", "user_id", name="plaza_id_user_id_unique"), 120 + ) 121 + id: int | None = Field(default=None, primary_key=True) 122 + # Meeting session that the attendance makes reference 123 + plaza_id: int = Field(foreign_key="plaza.id") 124 + plaza: Plaza = Relationship( 125 + back_populates="attendances", 126 + sa_relationship_kwargs=dict(foreign_keys="[Attendance.plaza_id]"), 127 + ) 128 + # User who is going to attend to the meeting session 129 + user_id: int = Field(foreign_key="user.id") 130 + user: User = Relationship( 131 + back_populates="attendances", 132 + sa_relationship_kwargs=dict(foreign_keys="[Attendance.user_id]"), 133 + ) 134 + transactions: list["Transaction"] = Relationship(back_populates="attendance")
-239
kefi/models/helpers.py
··· 1 - from typing import Dict, List, Optional, Tuple, Union 2 - 3 - from slack_sdk import WebClient 4 - from sqlmodel import Session, func, or_, select 5 - 6 - from kefi.config import settings 7 - from kefi.constants import Command 8 - from kefi.dependencies import SlashCommandParams 9 - from kefi.models.database import Action, Transaction, User 10 - from kefi.routers.responses import ActionResponse, ResetResponse 11 - from kefi.slack import Slack 12 - 13 - 14 - def get_or_create_from_command( 15 - command: SlashCommandParams, session: Session 16 - ) -> Tuple[User, bool]: 17 - """Obtains the balance of the user.""" 18 - query = select(User).filter(User.slack_user_id == command.user_id) 19 - user = session.exec(query).one_or_none() 20 - created = not user 21 - if not user: 22 - user = User(slack_user_id=command.user_id, slack_username=command.user_name) 23 - else: 24 - # Updates the slack username 25 - user.slack_username = command.user_name 26 - session.add(user) 27 - return (user, created) 28 - 29 - 30 - def available_balance(user: User, session: Session) -> int: 31 - """Obtains the availabe balance of the user, that means the balance not spend from 32 - the monthly received. 33 - """ 34 - query = select(func.sum(Transaction.amount)).filter( # type: ignore 35 - Transaction.user == user, 36 - or_(Transaction.sender == user, Transaction.sender == None), # type: ignore 37 - ) 38 - return session.exec(query).one() or 0 39 - 40 - 41 - def received_balance(user: User, session: Session) -> int: 42 - """Obtains the received balance of the user, that means the balance sent to the 43 - user. 44 - """ 45 - query = select(func.sum(Transaction.amount)).filter( # type: ignore 46 - Transaction.user == user, Transaction.receiver == user 47 - ) 48 - return session.exec(query).one() or 0 49 - 50 - 51 - def send_action( 52 - sender: User, action: Action, receiver: User, message: str, session: Session 53 - ) -> List[Transaction]: 54 - """Creates the transaction needed to send an action from sender to receiver.""" 55 - # Checks sender wallet 56 - balance = available_balance(user=sender, session=session) 57 - if balance < action.amount: 58 - raise ValueError("The user doesn't have enough balance") 59 - # Creates transactions 60 - sender_transaction = Transaction( 61 - action=action, 62 - amount=-action.amount, 63 - user=sender, 64 - sender=sender, 65 - receiver=receiver, 66 - message=message, 67 - ) 68 - receiver_transaction = Transaction( 69 - action=action, 70 - amount=action.amount, 71 - user=receiver, 72 - sender=sender, 73 - receiver=receiver, 74 - message=message, 75 - ) 76 - session.add(sender_transaction) 77 - session.add(receiver_transaction) 78 - return [sender_transaction, receiver_transaction] 79 - 80 - 81 - def notify_receiver_user_chat_action( 82 - action: Action, sender: User, receiver: User, message: str, channel_id: str 83 - ): 84 - slack = Slack() 85 - action_response = ActionResponse( 86 - sender=sender, 87 - receiver=receiver, 88 - action=action, 89 - message=message, 90 - channel_id=channel_id, 91 - ) 92 - 93 - text = None 94 - if action.text: 95 - text = action.text.format( 96 - sender_name=f"@{sender.slack_username}", 97 - receiver_name=f"@{receiver.slack_username}", 98 - ) 99 - 100 - slack.post_message_user( 101 - receiver.slack_user_id, 102 - text=text, 103 - blocks=action_response.render()["blocks"], 104 - ) 105 - 106 - 107 - def send_admin_amount(receiver: User, amount: int, session: Session) -> Transaction: 108 - """Creates the transaction needed to send an amount to the receiver.""" 109 - receiver_transaction = Transaction(amount=amount, user=receiver) 110 - session.add(receiver_transaction) 111 - return receiver_transaction 112 - 113 - 114 - def recharge_wallets(amount: int, session: Session): 115 - """Recharge all the wallet with the given amount.""" 116 - query = select(User) 117 - users = session.exec(query).all() 118 - for user in users: 119 - transaction = Transaction(amount=amount, user=user) # type: ignore 120 - session.add(transaction) 121 - 122 - 123 - def reset_wallets(session: Session): 124 - """Reset the wallets of all the users.""" 125 - users = session.exec(select(User)).all() 126 - for user in users: 127 - balance = available_balance(user=user, session=session) 128 - amount = settings.RECHARGE_KEFIS_AMOUNT - balance 129 - transaction = Transaction(amount=amount, user=user) # type: ignore 130 - session.add(transaction) 131 - 132 - 133 - def notify_reset_wallet(session: Session) -> None: 134 - """Sends notifications after the wallet was reset.""" 135 - amount = settings.RECHARGE_KEFIS_AMOUNT 136 - users = session.exec(select(User)).all() 137 - for user in users: 138 - slack = Slack() 139 - response = ResetResponse(amount=amount) 140 - slack.post_message_user(user.slack_user_id, blocks=response.render()["blocks"]) 141 - 142 - 143 - def create_default_actions(session: Session) -> None: 144 - """Creates the default actions if they doesn't exists.""" 145 - actions: List[Dict[str, Union[int, str]]] = [ 146 - { 147 - "keyword": Command.KUDOS, 148 - "amount": 100, 149 - "header_template": "¡Gracias {receiver_name}!", 150 - "message_template": "Mensaje de {sender_name}:\n _{message}_", 151 - "context_template": "*{sender_name}* le da a *{receiver_name}* {amount} kefis.", 152 - "image": "https://storage.staging.dekaside.com/kefi/static/images/kudos_400.png", 153 - "text": "{sender_name} le da las gracias a {receiver_name}", 154 - }, 155 - { 156 - "keyword": Command.CONGRATS, 157 - "amount": 25, 158 - "header_template": "¡Enhorabuena {receiver_name}!", 159 - "message_template": "Mensaje de {sender_name}:\n _{message}_", 160 - "context_template": "*{sender_name}* le da a *{receiver_name}* {amount} kefis.", 161 - "image": "https://storage.staging.dekaside.com/kefi/static/images/congrats_400.png", 162 - "text": "{sender_name} le da la enhorabuena a {receiver_name}", 163 - }, 164 - { 165 - "keyword": Command.HIGH_FIVE, 166 - "amount": 5, 167 - "header_template": "¡{sender_name} le envía un high five a {receiver_name}!", 168 - "message_template": "_{message}_", 169 - "context_template": "*{sender_name}* le da a *{receiver_name}* {amount} kefis.", 170 - "image": "https://storage.staging.dekaside.com/kefi/static/images/highfive_400.png", 171 - "text": "{sender_name} le envia un high five a {receiver_name}", 172 - }, 173 - ] 174 - for action_data in actions: 175 - action: Optional[Action] = session.exec( 176 - select(Action).filter(Action.keyword == action_data["keyword"]) 177 - ).one_or_none() 178 - if not action: 179 - session.add(Action(**action_data)) 180 - else: 181 - for key, value in action_data.items(): 182 - setattr(action, key, value) 183 - session.add(action) 184 - 185 - 186 - def find_user_by_slack_user_id(slack_user_id: str, session: Session) -> Optional[User]: 187 - """Gets the user using the Slack user id.""" 188 - query = select(User).filter(User.slack_user_id == slack_user_id) 189 - return session.exec(query).one_or_none() 190 - 191 - 192 - def find_user_by_slack_username( 193 - slack_username: str, session: Session 194 - ) -> Optional[User]: 195 - """Gets the user using the Slack username.""" 196 - query = select(User).filter(User.slack_username == slack_username) 197 - return session.exec(query).one_or_none() 198 - 199 - 200 - def get_action(keyword: str, session: Session) -> Optional[Action]: 201 - query = select(Action).filter(Action.keyword == keyword) 202 - return session.exec(query).one_or_none() 203 - 204 - 205 - def create_users(session: Session) -> None: 206 - """Creates all the users in the team.""" 207 - # Gets the users 208 - client = WebClient(token=settings.SLACK_BOT_TOKEN) 209 - response = client.users_list(team_id=settings.SLACK_TEAM_ID) 210 - members = response["members"] 211 - while response["response_metadata"]["next_cursor"]: 212 - response = client.users_list(team_id=settings.SLACK_TEAM_ID) 213 - members += response["members"] 214 - members = list( 215 - filter(lambda user: not user["is_bot"] and not user["deleted"], members) 216 - ) 217 - # Create or updates the users 218 - for member in members: 219 - query = select(User).filter(User.slack_user_id == member["id"]) 220 - user = session.exec(query).one_or_none() 221 - if not user: 222 - user = User( 223 - slack_user_id=member["id"], 224 - slack_username=member["name"], 225 - first_name=member["profile"].get("first_name", ""), 226 - last_name=member["profile"].get("last_name", ""), 227 - is_admin=member["is_admin"], 228 - ) 229 - else: 230 - user.slack_username = member["name"] 231 - user.is_admin = member["is_admin"] 232 - user.first_name = member["profile"].get("first_name", "") 233 - user.last_name = member["profile"].get("last_name", "") 234 - session.add(user) 235 - 236 - 237 - def get_all_users(session: Session) -> List[User]: 238 - """Gets the complete list of users.""" 239 - return session.exec(select(User)).all()
kefi/models/kudos/__init__.py

This is a binary file and will not be displayed.

+113
kefi/models/kudos/helpers.py
··· 1 + from sqlmodel import Session, select 2 + 3 + from kefi.constants import Command 4 + from kefi.models.core.helpers import available_balance 5 + from kefi.models.database import Action, Transaction, User 6 + from kefi.routers.responses import ActionResponse 7 + from kefi.slack import Slack 8 + 9 + 10 + def create_default_actions(session: Session) -> None: 11 + """Creates the default actions if they doesn't exists.""" 12 + actions: list[dict[str, int | str]] = [ 13 + { 14 + "keyword": Command.KUDOS, 15 + "amount": 100, 16 + "header_template": "¡Gracias {receiver_name}!", 17 + "message_template": "Mensaje de {sender_name}:\n _{message}_", 18 + "context_template": "*{sender_name}* le da a *{receiver_name}* {amount} kefis.", 19 + "image": "https://storage.staging.dekaside.com/kefi/static/images/kudos_400.png", 20 + "text": "{sender_name} le da las gracias a {receiver_name}", 21 + }, 22 + { 23 + "keyword": Command.CONGRATS, 24 + "amount": 25, 25 + "header_template": "¡Enhorabuena {receiver_name}!", 26 + "message_template": "Mensaje de {sender_name}:\n _{message}_", 27 + "context_template": "*{sender_name}* le da a *{receiver_name}* {amount} kefis.", 28 + "image": "https://storage.staging.dekaside.com/kefi/static/images/congrats_400.png", 29 + "text": "{sender_name} le da la enhorabuena a {receiver_name}", 30 + }, 31 + { 32 + "keyword": Command.HIGH_FIVE, 33 + "amount": 5, 34 + "header_template": "¡{sender_name} le envía un high five a {receiver_name}!", 35 + "message_template": "_{message}_", 36 + "context_template": "*{sender_name}* le da a *{receiver_name}* {amount} kefis.", 37 + "image": "https://storage.staging.dekaside.com/kefi/static/images/highfive_400.png", 38 + "text": "{sender_name} le envia un high five a {receiver_name}", 39 + }, 40 + ] 41 + for action_data in actions: 42 + action: Action | None = session.exec( 43 + select(Action).filter(Action.keyword == action_data["keyword"]) 44 + ).one_or_none() 45 + if not action: 46 + session.add(Action(**action_data)) 47 + else: 48 + for key, value in action_data.items(): 49 + setattr(action, key, value) 50 + session.add(action) 51 + 52 + 53 + def send_action( 54 + sender: User, action: Action, receiver: User, message: str, session: Session 55 + ) -> list["Transaction"]: 56 + """Creates the transaction needed to send an action from sender to receiver.""" 57 + # Checks sender wallet 58 + balance = available_balance(user=sender, session=session) 59 + if balance < action.amount: 60 + raise ValueError("The user doesn't have enough balance") 61 + # Creates transactions 62 + sender_transaction = Transaction( 63 + action=action, 64 + amount=-action.amount, 65 + user=sender, 66 + sender=sender, 67 + receiver=receiver, 68 + message=message, 69 + ) 70 + receiver_transaction = Transaction( 71 + action=action, 72 + amount=action.amount, 73 + user=receiver, 74 + sender=sender, 75 + receiver=receiver, 76 + message=message, 77 + ) 78 + session.add(sender_transaction) 79 + session.add(receiver_transaction) 80 + return [sender_transaction, receiver_transaction] 81 + 82 + 83 + def notify_receiver_user_chat_action( 84 + action: Action, sender: User, receiver: User, message: str, channel_id: str 85 + ): 86 + """Sends a notification that someone has send kefis to you.""" 87 + slack = Slack() 88 + action_response = ActionResponse( 89 + sender=sender, 90 + receiver=receiver, 91 + action=action, 92 + message=message, 93 + channel_id=channel_id, 94 + ) 95 + 96 + text = None 97 + if action.text: 98 + text = action.text.format( 99 + sender_name=f"@{sender.slack_username}", 100 + receiver_name=f"@{receiver.slack_username}", 101 + ) 102 + 103 + slack.post_message_user( 104 + receiver.slack_user_id, 105 + text=text, 106 + blocks=action_response.render()["blocks"], 107 + ) 108 + 109 + 110 + def get_action(keyword: str, session: Session) -> Action | None: 111 + """Gets an action using the keyword.""" 112 + query = select(Action).filter(Action.keyword == keyword) 113 + return session.exec(query).one_or_none()
+10 -12
kefi/models/outputs.py
··· 1 - from typing import List, Optional, Union 2 - 3 1 from pydantic import BaseModel 4 2 5 3 ··· 33 31 """Images accessories entries.""" 34 32 35 33 type: str = "image" 36 - image_url: Optional[str] 37 - alt_text: Optional[str] 34 + image_url: str | None 35 + alt_text: str | None 38 36 39 37 40 38 class Block(BaseModel): 41 39 """Base block entries.""" 42 40 43 41 type: str 44 - text: Optional[Text] 42 + text: Text | None 45 43 46 44 47 45 class Section(Block): 48 46 """Section blocks.""" 49 47 50 48 type: str = "section" 51 - fields: Optional[List[Text]] 52 - accessory: Optional[Accessory] 49 + fields: list[Text] | None 50 + accessory: Accessory | None 53 51 54 52 55 53 class Header(Block): ··· 63 61 """Context block""" 64 62 65 63 type: str = "context" 66 - elements: List[Union[Text, Image]] 64 + elements: list[Text | Image] 67 65 68 66 69 67 class Response(BaseModel): 70 68 """Main response for Slack commands.""" 71 69 72 - channel: Optional[str] 73 - text: Optional[str] 74 - response_type: Optional[str] = "ephemeral" 75 - blocks: List[Block] 70 + channel: str | None 71 + text: str | None 72 + response_type: str | None = "ephemeral" 73 + blocks: list[Block]
kefi/models/plazas/__init__.py

This is a binary file and will not be displayed.

+6
kefi/models/plazas/exceptions.py
··· 1 + class AlreadyAttending(Exception): 2 + ... 3 + 4 + 5 + class NotAttending(Exception): 6 + ...
+131
kefi/models/plazas/helpers.py
··· 1 + import datetime 2 + import random 3 + import uuid 4 + 5 + import pytz 6 + from sqlmodel import Session, select 7 + 8 + from kefi.config import settings 9 + from kefi.models.core.exceptions import NotEnoughKefi 10 + from kefi.models.core.helpers import available_balance 11 + from kefi.models.database import Attendance, Plaza, Transaction, User 12 + from kefi.models.plazas.exceptions import AlreadyAttending, NotAttending 13 + from kefi.slack import Slack 14 + 15 + 16 + def generate_random_meet() -> tuple[str, str]: 17 + """Generates a call id and a link to this meet id.""" 18 + meet_id = str(uuid.uuid4()) 19 + return meet_id, f"http://g.co/meet/kefi-plaza-{meet_id}" 20 + 21 + 22 + def next_plaza_appointment() -> datetime.datetime: 23 + """Gets the next plaza date and time appointment.""" 24 + today = datetime.datetime.today() 25 + next_date = (today + datetime.timedelta((4 - today.weekday()) % 7)).date() 26 + default_time = datetime.time( 27 + hour=settings.PLAZA_DEFAULT_HOUR, minute=settings.PLAZA_DEFAULT_MINUTE 28 + ) 29 + return datetime.datetime.combine(date=next_date, time=default_time).astimezone( 30 + pytz.timezone("Europe/Madrid") 31 + ) 32 + 33 + 34 + def get_or_create_current_plaza(session: Session) -> Plaza: 35 + """Gets the current available plaza.""" 36 + now = datetime.datetime.now(tz=pytz.utc) 37 + query = select(Plaza).filter(Plaza.date >= now).order_by("date") 38 + results = session.exec(query) 39 + first = results.first() 40 + if first: 41 + return first 42 + plaza = Plaza(date=next_plaza_appointment()) 43 + session.add(plaza) 44 + return plaza 45 + 46 + 47 + def is_attending(user: User, session: Session) -> bool: 48 + """Checks if the user is attending to the current plaza.""" 49 + plaza = get_or_create_current_plaza(session=session) 50 + query = select(Attendance).filter( 51 + Attendance.user == user, Attendance.plaza == plaza 52 + ) 53 + results = session.exec(query) 54 + return results.first() is not None 55 + 56 + 57 + def create_attendance(user: User, session: Session) -> Attendance: 58 + """Creates the transaction that spends the kefis and also creates the attendance 59 + for the next plaza. 60 + """ 61 + plaza = get_or_create_current_plaza(session=session) 62 + balance = available_balance(user=user, session=session) 63 + price = settings.PLAZA_PRICE 64 + if balance < price: 65 + raise NotEnoughKefi("The user doesn't have enough balance") 66 + query = select(Attendance).filter( 67 + Attendance.user == user, Attendance.plaza == plaza 68 + ) 69 + results = session.exec(query) 70 + if results.first(): 71 + raise AlreadyAttending("The user is already in the plaza") 72 + attendance = Attendance(plaza=plaza, user=user) 73 + session.add(attendance) 74 + transaction = Transaction(amount=-price, user=user, attendance=attendance) 75 + session.add(transaction) 76 + return attendance 77 + 78 + 79 + def delete_attendance(user: User, session: Session) -> None: 80 + """Deletes the attendance and the associated transaction.""" 81 + plaza = get_or_create_current_plaza(session=session) 82 + attendance_query = select(Attendance).filter( 83 + Attendance.user == user, Attendance.plaza == plaza 84 + ) 85 + attendance_results = session.exec(attendance_query) 86 + attendance = attendance_results.first() 87 + if not attendance: 88 + raise NotAttending("The user is not in the plaza") 89 + transaction_query = select(Transaction).filter( 90 + Transaction.attendance == attendance, Transaction.user == user 91 + ) 92 + transaction_results = session.exec(transaction_query) 93 + transaction = transaction_results.first() 94 + session.delete(transaction) 95 + session.delete(attendance) 96 + 97 + 98 + def plaza_groups(session: Session, plaza: Plaza | None = None) -> list[list]: 99 + """Generates the groups for the current plaza or the given plaza.""" 100 + current_plaza: Plaza = plaza or get_or_create_current_plaza(session=session) 101 + query = select(Attendance).filter(Attendance.plaza == current_plaza) 102 + results = session.exec(query).all() 103 + users = [attendance.user for attendance in results] 104 + random.shuffle(users) 105 + return [ 106 + users[index : index + settings.PLAZA_SIZE] 107 + for index in range(0, len(users), settings.PLAZA_SIZE) 108 + ] 109 + 110 + 111 + def notify_plaza(session: Session, plaza: Plaza | None = None): 112 + slack = Slack() 113 + current_plaza: Plaza = plaza or get_or_create_current_plaza(session=session) 114 + groups = plaza_groups(session=session, plaza=current_plaza) 115 + for group in groups: 116 + slack_users = [user.slack_user_id for user in group] 117 + meet_id, meet_url = generate_random_meet() 118 + call = slack.create_group_call( 119 + url=meet_url, 120 + users=slack_users, 121 + external_unique_id=meet_id, 122 + ) 123 + slack.notify_call(users=slack_users, call_id=call["id"]) 124 + 125 + 126 + def select_current_plaza(session: Session) -> Plaza | None: 127 + """Gets the curren plaza that have to ve executed.""" 128 + now = datetime.datetime.now(tz=pytz.utc).replace(second=0, microsecond=0) 129 + query = select(Plaza).filter(Plaza.date == now).order_by("date") 130 + results = session.exec(query) 131 + return results.first()
+14
kefi/routers/events.py
··· 1 + from fastapi import APIRouter, Depends 2 + from sqlmodel import Session 3 + 4 + from kefi.dependencies import EventBody, get_session 5 + from kefi.routers.helpers import EventHandler 6 + 7 + router = APIRouter() 8 + 9 + 10 + @router.post("/events/", tags=["events"]) 11 + def handle_events(event_body: EventBody, session: Session = Depends(get_session)): 12 + """Calls the events handler and gets the response.""" 13 + event_handler = EventHandler(event_body=event_body, session=session) 14 + return event_handler.response()
+230 -17
kefi/routers/helpers.py
··· 1 + import json 1 2 import re 2 - from typing import Callable, Dict, List, Optional, Tuple 3 + from collections.abc import Callable 3 4 4 5 from sqlmodel import Session 5 6 6 - from kefi.constants import Command 7 - from kefi.dependencies import SlashCommandParams 8 - from kefi.models.helpers import ( 7 + from kefi.constants import Actions, Command, EventBodyType, InteractionType 8 + from kefi.dependencies import EventBody, InteractionParams, SlashCommandParams 9 + from kefi.models.core.exceptions import NotEnoughKefi 10 + from kefi.models.core.helpers import ( 9 11 available_balance, 10 12 find_user_by_slack_user_id, 11 - get_action, 12 13 get_all_users, 13 14 get_or_create_from_command, 15 + get_or_create_from_event, 16 + get_or_create_from_interactivity, 17 + received_balance, 18 + send_admin_amount, 19 + ) 20 + from kefi.models.database import User 21 + from kefi.models.kudos.helpers import ( 22 + get_action, 14 23 notify_receiver_user_chat_action, 15 - received_balance, 16 24 send_action, 17 - send_admin_amount, 25 + ) 26 + from kefi.models.plazas.exceptions import AlreadyAttending, NotAttending 27 + from kefi.models.plazas.helpers import ( 28 + create_attendance, 29 + delete_attendance, 30 + is_attending, 31 + ) 32 + from kefi.routers.messages import ( 33 + AlreadyAttendingMessage, 34 + BaseMessage, 35 + NotAttendingMessage, 36 + NotEnoughKefiMessage, 37 + UserJoinedMeetMessage, 38 + UserLeftMeetMessage, 18 39 ) 19 40 from kefi.routers.responses import ( 20 41 ActionResponse, ··· 24 45 SlackResponse, 25 46 WalletResponse, 26 47 ) 48 + from kefi.routers.views import HomeView, JoinMeetView, LeaveMeetView 49 + from kefi.slack import Slack 27 50 28 51 29 52 class CommandHandler: 30 53 """Handle different commands.""" 31 54 32 - def __init__(self, command: SlashCommandParams, session: Session): 55 + def __init__(self, command: "SlashCommandParams", session: "Session"): # type: ignore 33 56 self.session = session 34 57 self.command = command 35 58 self.user, _ = get_or_create_from_command(command=command, session=session) 36 59 37 - def extract_keyword_params(self, text: str) -> Tuple[str, List[str]]: 60 + def extract_keyword_params(self, text: str) -> tuple[str, list[str]]: 38 61 params = text.split() 39 62 return params[0], params[1:] 40 63 41 - def extract_user_id(self, text: str) -> Optional[str]: 64 + def extract_user_id(self, text: str) -> str | None: 42 65 found_user_id = re.search("<@([^\|]+)\|?(.+)?>", text) 43 66 return found_user_id.group(1) if found_user_id else None 44 67 45 - def response(self) -> Dict: 68 + def response(self) -> dict: 46 69 try: 47 70 keyword, params = self.extract_keyword_params(self.command.text) 48 71 except IndexError: 49 - return SimpleResponse("No he entenido el comando").render() 50 - handlers: Dict[str, Callable] = { 72 + return SimpleResponse("No he entendido el comando").render() 73 + handlers: dict[str, Callable] = { 51 74 Command.HELP: self.command_help, 52 75 Command.WALLET: self.command_wallet, 53 76 Command.KUDOS: self.command_action, ··· 60 83 ).render() 61 84 return response 62 85 63 - def command_help(self, *args, **kwargs) -> SlackResponse: 86 + def command_help(self, *args, **kwargs) -> "SlackResponse": 64 87 return HelpResponse() 65 88 66 89 def command_wallet(self, *args, **kwargs) -> SlackResponse: ··· 68 91 received_amount = received_balance(user=self.user, session=self.session) 69 92 return WalletResponse(self.user, remaining_amount, received_amount) 70 93 71 - def command_action(self, keyword: str, params: List[str]) -> SlackResponse: 94 + def command_action(self, keyword: str, params: list[str]) -> "SlackResponse": 72 95 action = get_action(keyword=keyword, session=self.session) 73 96 if not action: 74 97 return SimpleResponse("No existe la acción asociada a este comando") ··· 109 132 channel_id=self.command.channel_id, 110 133 ) 111 134 112 - def command_reward(self, keyword: str, params: List[str]) -> SlackResponse: 135 + def command_reward(self, keyword: str, params: list[str]) -> "SlackResponse": 113 136 amount = int(params[0]) 114 137 if not self.user.is_admin: 115 138 return SimpleResponse( ··· 136 159 ) 137 160 return SimpleResponse(f"Has enviado {amount} kefis") 138 161 139 - def not_found(self, *args, **kwargs) -> SlackResponse: 162 + def not_found(self, *args, **kwargs) -> "SlackResponse": 140 163 return NotFoundResponse() 164 + 165 + 166 + class InteractionHandler: 167 + def __init__(self, interaction: InteractionParams, session: Session): 168 + self.session = session 169 + self.payload = json.loads(interaction.payload) 170 + self.user, _ = get_or_create_from_interactivity( 171 + interactivity_payload=self.payload, session=session 172 + ) 173 + self.slack = Slack() 174 + 175 + def response(self) -> SlackResponse: 176 + handlers: dict[str, Callable] = { 177 + InteractionType.SHORTCUT: self.interaction_shortcut, 178 + InteractionType.BLOCK_ACTIONS: self.interaction_block_actions, 179 + InteractionType.VIEW_SUBMISSION: self.interaction_view_submission, 180 + } 181 + _type = self.payload["type"] 182 + response = handlers.get(_type, self.not_found)() 183 + return response 184 + 185 + def interaction_shortcut(self) -> list: 186 + """First interaction with the shortcut. Shows a view depending if the user is 187 + in the plaza or not. 188 + 189 + Important, we assume all the interactivity comes from `kefi_meets`, so, 190 + if we add more shortcuts, we need to check `callback_id`. 191 + """ 192 + trigger_id = self.payload["trigger_id"] 193 + if not is_attending(user=self.user, session=self.session): 194 + self.slack.open_view( 195 + trigger_id=trigger_id, view=JoinMeetView(session=self.session) 196 + ) 197 + else: 198 + self.slack.open_view( 199 + trigger_id=trigger_id, view=LeaveMeetView(session=self.session) 200 + ) 201 + return [] 202 + 203 + def interaction_block_actions(self) -> list: 204 + handler = ActionsHandler( 205 + payload=self.payload, 206 + session=self.session, 207 + user=self.user, 208 + ) 209 + handler.handle() 210 + return [] 211 + 212 + def _view_submission_meet_join(self, view_id: str) -> list | dict: 213 + """Joins the plaza.""" 214 + message: BaseMessage 215 + try: 216 + create_attendance(user=self.user, session=self.session) 217 + message = UserJoinedMeetMessage() 218 + self.slack.post_message_user( 219 + user_id=self.user.slack_user_id, 220 + blocks=message.blocks(), 221 + text=message.text(), 222 + ) 223 + except NotEnoughKefi: 224 + message = NotEnoughKefiMessage() 225 + self.slack.post_message_user( 226 + user_id=self.user.slack_user_id, 227 + blocks=message.blocks(), 228 + text=message.text(), 229 + ) 230 + except AlreadyAttending: 231 + message = AlreadyAttendingMessage() 232 + self.slack.post_message_user( 233 + user_id=self.user.slack_user_id, 234 + blocks=message.blocks(), 235 + text=message.text(), 236 + ) 237 + return {"response_action": "clear"} 238 + 239 + def _view_submission_meet_leave(self, view_id: str) -> list | dict: 240 + """Leaves the plaza.""" 241 + message: BaseMessage 242 + try: 243 + delete_attendance(user=self.user, session=self.session) 244 + message = UserLeftMeetMessage() 245 + self.slack.post_message_user( 246 + user_id=self.user.slack_user_id, 247 + blocks=message.blocks(), 248 + text=message.text(), 249 + ) 250 + except NotAttending: 251 + message = NotAttendingMessage() 252 + self.slack.post_message_user( 253 + user_id=self.user.slack_user_id, 254 + blocks=message.blocks(), 255 + text=message.text(), 256 + ) 257 + return {"response_action": "clear"} 258 + 259 + def interaction_view_submission(self) -> list | dict: 260 + """Handles the submissions from a view.""" 261 + view_id = self.payload["view"]["id"] 262 + view_callback_id = self.payload["view"]["callback_id"] 263 + try: 264 + return getattr(self, f"_view_submission_{view_callback_id}")( 265 + view_id=view_id 266 + ) 267 + except AttributeError: 268 + ... 269 + return [] 270 + 271 + def not_found(self) -> list: 272 + return [] 273 + 274 + 275 + class ActionsHandler: 276 + def __init__(self, session: Session, user: User, payload: dict): 277 + self.payload = payload 278 + self.session = session 279 + self.user = user 280 + self.slack = Slack() 281 + 282 + def handle(self): 283 + actions = self.payload.get("actions") 284 + if actions is not None: 285 + for action in actions: 286 + handlers: dict[str, Callable] = { 287 + Actions.LEAVE_MEET: self.action_leave_meet, 288 + Actions.SHOW_MEETS_MODAL: self.action_show_meets_modal, 289 + } 290 + handlers.get(action["action_id"], self.not_found)() 291 + 292 + def not_found(self): 293 + ... 294 + 295 + def action_leave_meet(self): 296 + message: BaseMessage 297 + try: 298 + delete_attendance(user=self.user, session=self.session) 299 + message = UserLeftMeetMessage() 300 + self.slack.update_message( 301 + channel=self.payload["channel"]["id"], 302 + blocks=message.blocks(), 303 + text=message.text(), 304 + timestamp=self.payload["message"]["ts"], 305 + ) 306 + except NotAttending: 307 + message = NotAttendingMessage() 308 + self.slack.post_message_user( 309 + user_id=self.user.slack_user_id, 310 + blocks=message.blocks(), 311 + text=message.text(), 312 + ) 313 + 314 + def action_show_meets_modal(self): 315 + trigger_id = self.payload["trigger_id"] 316 + if not is_attending(user=self.user, session=self.session): 317 + self.slack.open_view( 318 + trigger_id=trigger_id, view=JoinMeetView(session=self.session) 319 + ) 320 + else: 321 + self.slack.open_view( 322 + trigger_id=trigger_id, view=LeaveMeetView(session=self.session) 323 + ) 324 + 325 + 326 + class EventHandler: 327 + def __init__(self, event_body: EventBody, session: Session): 328 + self.event_body = event_body 329 + self.session = session 330 + self.slack = Slack() 331 + if self.event_body.event: 332 + self.user, _ = get_or_create_from_event( 333 + event=self.event_body.event, session=session 334 + ) 335 + 336 + def response(self) -> list | dict: 337 + handlers: dict[str, Callable] = { 338 + EventBodyType.URL_VERIFICATION: self.handle_url_verification, 339 + EventBodyType.EVENT_CALLBACK: self.handle_event_callback, 340 + } 341 + response = handlers.get(self.event_body.type, self.not_found)() 342 + return response 343 + 344 + def not_found(self) -> list: 345 + return [] 346 + 347 + def handle_url_verification(self) -> dict: 348 + """Handles the endpoint verification.""" 349 + return {"challenge": self.event_body.challenge} 350 + 351 + def handle_event_callback(self) -> list: 352 + self.slack.publish_view(user_id=self.user.slack_user_id, view=HomeView()) 353 + return []
+17
kefi/routers/interactivity.py
··· 1 + from fastapi import APIRouter, Depends 2 + from sqlmodel import Session 3 + 4 + from kefi.dependencies import InteractionParams, get_session 5 + from kefi.routers.helpers import InteractionHandler 6 + 7 + router = APIRouter() 8 + 9 + 10 + @router.post("/interactivity/", tags=["interactivity"]) 11 + def handle_interactivity( 12 + session: Session = Depends(get_session), 13 + interaction: InteractionParams = Depends(InteractionParams), 14 + ): 15 + """Calls the interaction handler and gets the response.""" 16 + interaction_handler = InteractionHandler(interaction=interaction, session=session) 17 + return interaction_handler.response()
+79
kefi/routers/messages.py
··· 1 + from slack_sdk.models.blocks import ( 2 + Block, 3 + ButtonElement, 4 + DividerBlock, 5 + MarkdownTextObject, 6 + PlainTextObject, 7 + SectionBlock, 8 + ) 9 + 10 + from kefi.constants import Actions 11 + 12 + 13 + class BaseMessage: 14 + def text(self) -> str: 15 + pass 16 + 17 + def blocks(self) -> list[Block]: 18 + pass 19 + 20 + 21 + class UserJoinedMeetMessage(BaseMessage): 22 + def text(self) -> str: 23 + return ":tada: ¡Genial! ¡Nos vemos el próximo viernes!" 24 + 25 + def blocks(self) -> list[Block]: 26 + return [ 27 + SectionBlock( 28 + text=MarkdownTextObject( 29 + text=":tada: ¡Genial! ¡Nos vemos el próximo viernes!" 30 + ) 31 + ), 32 + DividerBlock(), 33 + SectionBlock( 34 + text=MarkdownTextObject(text="¿Cambio de planes?"), 35 + accessory=ButtonElement( 36 + text=PlainTextObject(text="No asistiré"), 37 + style="danger", 38 + action_id=Actions.LEAVE_MEET, 39 + ), 40 + ), 41 + ] 42 + 43 + 44 + class UserLeftMeetMessage(BaseMessage): 45 + def text(self) -> str: 46 + return ":disappointed: ¡Vaya! ¡Esperamos verte la próxima vez!" 47 + 48 + def blocks(self) -> list[Block]: 49 + return [ 50 + SectionBlock( 51 + text=MarkdownTextObject( 52 + text=":disappointed: ¡Vaya! ¡Esperamos verte la próxima vez!" 53 + ) 54 + ), 55 + ] 56 + 57 + 58 + class NotEnoughKefiMessage(BaseMessage): 59 + def text(self) -> str: 60 + return "NotEnoughKefiMessage" 61 + 62 + def blocks(self) -> list[Block]: 63 + return [SectionBlock(text="NotEnoughKefiMessage")] 64 + 65 + 66 + class AlreadyAttendingMessage(BaseMessage): 67 + def text(self) -> str: 68 + return "AlreadyAttendingMessage" 69 + 70 + def blocks(self) -> list[Block]: 71 + return [SectionBlock(text="AlreadyAttendingMessage")] 72 + 73 + 74 + class NotAttendingMessage(BaseMessage): 75 + def text(self) -> str: 76 + return "NotAttendingMessage" 77 + 78 + def blocks(self) -> list[Block]: 79 + return [SectionBlock(text="NotAttendingMessage")]
+4 -6
kefi/routers/responses.py
··· 1 - from typing import Any, Dict, List, Optional 2 - 3 1 from kefi.models.database import Action, User 4 2 from kefi.models.outputs import ( 5 3 Context, ··· 15 13 class SlackResponse: 16 14 """Base class for a Slack response.""" 17 15 18 - response: Optional[Response] 16 + response: Response | None 19 17 20 - def render(self) -> Dict: 18 + def render(self) -> dict: 21 19 """Render the response model, getting the dict..""" 22 20 return self.response.dict(exclude_none=True) if self.response else {} 23 21 ··· 67 65 action: Action, 68 66 message: str, 69 67 channel_id: str, 70 - response_type: Optional[str] = "in_channel", 68 + response_type: str | None = "in_channel", 71 69 ) -> None: 72 70 super().__init__() 73 71 # Saves the data ··· 82 80 83 81 def build_response(self) -> None: 84 82 """Creates the response using the data in action and""" 85 - blocks: List[Any] = [] 83 + blocks: list = [] 86 84 blocks.append( 87 85 Header( 88 86 text=PlainText(
+176
kefi/routers/views.py
··· 1 + from slack_sdk.models.blocks import ( 2 + ActionsBlock, 3 + ButtonElement, 4 + ContextBlock, 5 + DividerBlock, 6 + HeaderBlock, 7 + ImageElement, 8 + MarkdownTextObject, 9 + PlainTextObject, 10 + SectionBlock, 11 + ) 12 + from slack_sdk.models.views import View 13 + 14 + from kefi.config import Settings 15 + from kefi.constants import Actions, ViewType 16 + from kefi.models.plazas.helpers import get_or_create_current_plaza 17 + 18 + 19 + class JoinMeetView(View): 20 + def __init__(self, *args, **kwargs): 21 + session = kwargs.get("session") 22 + next_plaza = get_or_create_current_plaza(session=session) 23 + date = next_plaza.local_date() 24 + super().__init__( 25 + callback_id=ViewType.JOIN_MEET, 26 + type="modal", 27 + title=PlainTextObject(text="Kefi"), 28 + close=PlainTextObject(text="Cancelar"), 29 + submit=PlainTextObject(text="¡Me apunto!"), 30 + blocks=[ 31 + SectionBlock( 32 + text=MarkdownTextObject( 33 + text="*:wave: ¡Hola! ¿Te vienes a la Kefi Plaza?*" 34 + ) 35 + ), 36 + DividerBlock(), 37 + SectionBlock( 38 + text=MarkdownTextObject( 39 + text=f"*Próximo encuentro*\n{date.strftime('%A, %-d %B')}\n{date.strftime('%H:%M')}\n" 40 + ), 41 + accessory=ImageElement( 42 + image_url="https://api.slack.com/img/blocks/bkb_template_images/notifications.png", 43 + alt_text="calendar thumbnail", 44 + ), 45 + ), 46 + ], 47 + ) 48 + 49 + 50 + class LeaveMeetView(View): 51 + def __init__(self, *args, **kwargs): 52 + session = kwargs.get("session") 53 + next_plaza = get_or_create_current_plaza(session=session) 54 + date = next_plaza.local_date() 55 + super().__init__( 56 + callback_id=ViewType.LEAVE_MEET, 57 + type="modal", 58 + title=PlainTextObject(text="Kefi"), 59 + close=PlainTextObject(text="Cancelar"), 60 + submit=PlainTextObject(text="No asistiré"), 61 + blocks=[ 62 + SectionBlock( 63 + text=MarkdownTextObject( 64 + text="*:wave: ¡Ei! ¡Nos vemos en la Kefi Plaza!*" 65 + ) 66 + ), 67 + DividerBlock(), 68 + SectionBlock( 69 + text=MarkdownTextObject( 70 + text=f"*Kefi Plaza*\n{date.strftime('%A, %-d %B')}\n{date.strftime('%H:%M')}\n" 71 + ), 72 + accessory=ImageElement( 73 + image_url="https://api.slack.com/img/blocks/bkb_template_images/notifications.png", 74 + alt_text="calendar thumbnail", 75 + ), 76 + ), 77 + DividerBlock(), 78 + ContextBlock( 79 + elements=[ 80 + MarkdownTextObject( 81 + text="*:white_check_mark: Has indicado que asistirás.*" 82 + ) 83 + ] 84 + ), 85 + DividerBlock(), 86 + SectionBlock(text=MarkdownTextObject(text="*¿Cambio de planes?*")), 87 + ], 88 + ) 89 + 90 + 91 + class HomeView(View): 92 + def __init__(self, *args, **kwargs): 93 + super().__init__( 94 + type="home", 95 + blocks=[ 96 + HeaderBlock( 97 + text=PlainTextObject(text="Esto es lo que puedes hacer con Kefi:") 98 + ), 99 + HeaderBlock(text=PlainTextObject(text="¡Gracias!")), 100 + SectionBlock( 101 + text=MarkdownTextObject( 102 + text="Hazle llegar tu agradecimiento a un compañero por ayudarte en un proyecto." 103 + ), 104 + accessory=ImageElement( 105 + image_url="https://storage.staging.dekaside.com/kefi/static/images/kudos.png", 106 + alt_text="Kudos", 107 + ), 108 + ), 109 + ContextBlock( 110 + elements=[PlainTextObject(text="/kefi kudos @user [mensaje]")] 111 + ), 112 + DividerBlock(), 113 + HeaderBlock(text=PlainTextObject(text="¡Enhorabuena!")), 114 + SectionBlock( 115 + text=MarkdownTextObject( 116 + text="Un trabajo bien hecho, una buena idea, la certificación en un nuevo curso... ¡se merecen una enhorabuena!" 117 + ), 118 + accessory=ImageElement( 119 + image_url="https://storage.staging.dekaside.com/kefi/static/images/congrats.png", 120 + alt_text="Congrats", 121 + ), 122 + ), 123 + ContextBlock( 124 + elements=[PlainTextObject(text="/kefi congrats @user [mensaje]")] 125 + ), 126 + DividerBlock(), 127 + HeaderBlock(text=PlainTextObject(text="¡High Five!")), 128 + SectionBlock( 129 + text=MarkdownTextObject( 130 + text="Alguien ha sido un buen colega y te ha alegrado el día." 131 + ), 132 + accessory=ImageElement( 133 + image_url="https://storage.staging.dekaside.com/kefi/static/images/highfive.png", 134 + alt_text="High five", 135 + ), 136 + ), 137 + ContextBlock( 138 + elements=[PlainTextObject(text="/kefi highfive @user [mensaje]")] 139 + ), 140 + DividerBlock(), 141 + HeaderBlock(text=PlainTextObject(text="Kefi Plaza")), 142 + SectionBlock( 143 + text=MarkdownTextObject( 144 + text=f"Cada viernes se asignan aleatoriamente grupos de {Settings.PLAZA_SIZE} personas en una sala virtual para compartir un Kefi online y hablar de los que más os guste. ¿Te unes?" 145 + ), 146 + accessory=ImageElement( 147 + image_url="https://storage.staging.dekaside.com/kefi/static/images/kefi_plaza.png", 148 + alt_text="Plaza", 149 + ), 150 + ), 151 + ActionsBlock( 152 + elements=[ 153 + ButtonElement( 154 + text=PlainTextObject(text="¡Me Apunto!"), 155 + action_id=Actions.SHOW_MEETS_MODAL, 156 + ) 157 + ] 158 + ), 159 + DividerBlock(), 160 + HeaderBlock(text=PlainTextObject(text="Consulta tu saldo")), 161 + SectionBlock( 162 + text=MarkdownTextObject( 163 + text="Podrás consultar tu saldo de Kefis pendientes de gastar así como el acumulado en la hucha de recibidos cuantas veces quieras." 164 + ), 165 + ), 166 + ContextBlock(elements=[PlainTextObject(text="/kefi wallet")]), 167 + DividerBlock(), 168 + HeaderBlock(text=PlainTextObject(text="¿Necesitas ayuda?")), 169 + SectionBlock( 170 + text=MarkdownTextObject( 171 + text="No te preocupes, para saber más solo ingresa en el campo de texto de Slack el comando:" 172 + ), 173 + ), 174 + ContextBlock(elements=[PlainTextObject(text="/kefi help")]), 175 + ], 176 + )
+57 -4
kefi/slack.py
··· 1 - from typing import Optional, Sequence 1 + import uuid 2 + from collections.abc import Sequence 2 3 4 + from slack_sdk.models.blocks import Block 5 + from slack_sdk.models.views import View 3 6 from slack_sdk.web.client import WebClient 4 7 5 8 from kefi.config import settings ··· 9 12 def __init__(self) -> None: 10 13 self.client = WebClient(token=settings.SLACK_BOT_TOKEN) 11 14 12 - def get_users(self, cursor: Optional[str] = None, users=[]): 15 + def get_users(self, cursor: str | None = None, users=[]): 13 16 result = self.client.users_list(team_id=settings.SLACK_TEAM_ID, cursor=cursor) 14 17 users = users + result["members"] 15 18 next_cursor = result["response_metadata"]["next_cursor"] ··· 20 23 else: 21 24 return users 22 25 23 - def get_user_chats(self, cursor: Optional[str] = None, user_chats=[]): 26 + def get_user_chats(self, cursor: str | None = None, user_chats=[]): 24 27 result = self.client.conversations_list( 25 28 team_id=settings.SLACK_TEAM_ID, cursor=cursor, types=["im"] 26 29 ) ··· 40 43 return user_chats 41 44 42 45 def post_message_user( 43 - self, user_id: str, blocks: Sequence[dict], text: Optional[str] = None 46 + self, user_id: str, blocks: Sequence[dict | Block], text: str | None = None 44 47 ): 45 48 self.client.chat_postMessage(channel=user_id, blocks=blocks, text=text) 49 + 50 + def update_message( 51 + self, 52 + channel: str, 53 + timestamp: str, 54 + blocks: Sequence[dict | Block], 55 + text: str | None = None, 56 + ): 57 + self.client.chat_update(channel=channel, blocks=blocks, text=text, ts=timestamp) 58 + 59 + def open_view(self, trigger_id: str, view: View): 60 + self.client.views_open(trigger_id=trigger_id, view=view) 61 + 62 + def update_view(self, view_id: str, view: View): 63 + self.client.views_update(view_id=view_id, view=view) 64 + 65 + def push_view(self, trigger_id: str, view: View): 66 + self.client.views_push(trigger_id=trigger_id, view=view) 67 + 68 + def publish_view(self, user_id: str, view: View): 69 + self.client.views_publish(user_id=user_id, view=view) 70 + 71 + def create_group_call( 72 + self, url: str, users: Sequence[str], external_unique_id: str | None = None 73 + ): 74 + external_unique_id = external_unique_id or str(uuid.uuid4()) 75 + response = self.client.calls_add( 76 + external_unique_id=external_unique_id, join_url=url 77 + ) 78 + call_id = response["call"]["id"] 79 + self.add_to_group(users, call_id) 80 + return self.client.calls_info(id=call_id)["call"] 81 + 82 + def add_to_group(self, users: Sequence[str], call_id: str): 83 + self.client.calls_participants_add( 84 + id=call_id, users=[{"slack_id": user} for user in users] 85 + ) 86 + 87 + def notify_call(self, users: Sequence[str], call_id: str): 88 + chat_notify: Sequence[dict[str, str]] = [ 89 + { 90 + "type": "call", 91 + "call_id": call_id, 92 + } 93 + ] 94 + for user in users: 95 + self.client.chat_postMessage(channel=user, blocks=chat_notify) 96 + 97 + def end_call(self, call_id: str): 98 + self.client.calls_end(id=call_id)
+5 -1
kefi/tasks/main.py
··· 4 4 5 5 from kefi.config import settings 6 6 from kefi.models.database import engine 7 + from kefi.tasks.plazas import runs_plaza 7 8 from kefi.tasks.wallets import recharge_wallets_task 8 9 9 10 ··· 18 19 19 20 class WorkerSettings: 20 21 redis_settings = RedisSettings(host=settings.REDIS_HOST) 21 - cron_jobs = [cron(recharge_wallets_task, day=1, hour=0, minute=0)] # type: ignore 22 + cron_jobs = [ 23 + cron(recharge_wallets_task, day=1, hour=0, minute=0), # type: ignore 24 + cron(runs_plaza, minute=0), # type: ignore 25 + ] 22 26 on_startup = startup 23 27 on_shutdown = shutdown
+11
kefi/tasks/plazas.py
··· 1 + from kefi.models.plazas.helpers import notify_plaza, select_current_plaza 2 + 3 + 4 + async def runs_plaza(ctx): 5 + """Task to runs the plaza, that means, create the groups and send the call.""" 6 + session = ctx["session"] 7 + plaza = select_current_plaza(session=session) 8 + if plaza: 9 + notify_plaza(session=session, plaza=plaza) 10 + # In case the helper saves info 11 + ctx["session"].commit()
+1 -1
kefi/tasks/wallets.py
··· 1 - from kefi.models.helpers import notify_reset_wallet, reset_wallets 1 + from kefi.models.core.helpers import notify_reset_wallet, reset_wallets 2 2 3 3 4 4 async def recharge_wallets_task(ctx):
+1 -1
kefi/tests/conftest.py
··· 9 9 from kefi.config import settings 10 10 from kefi.dependencies import get_session 11 11 from kefi.main import app 12 - from kefi.models.helpers import create_default_actions 12 + from kefi.models.kudos.helpers import create_default_actions 13 13 14 14 15 15 def run_migrations(url):
+5 -5
kefi/tests/test_commands.py
··· 4 4 from sqlmodel import Session 5 5 from sqlmodel.sql.expression import select 6 6 7 + from kefi.models.core.helpers import available_balance, reset_wallets 7 8 from kefi.models.database import Transaction, User 8 - from kefi.models.helpers import available_balance, reset_wallets 9 9 from kefi.routers.helpers import Command 10 10 from kefi.tests.helpers import generate_command 11 11 12 12 13 - def test_unknown_commad(session: Session, client: TestClient): 13 + def test_unknown_command(session: Session, client: TestClient): 14 14 """Test command /kefi unknown""" 15 15 16 16 response = client.post("/command/", data=generate_command(text="unknown")) ··· 19 19 assert len(users) == 1 20 20 21 21 22 - def test_empty_commad(client: TestClient): 22 + def test_empty_command(client: TestClient): 23 23 """Test command /kefi""" 24 24 25 25 response = client.post("/command/", data=generate_command(text="")) 26 26 assert response.status_code == 422 27 27 28 28 29 - def test_help_commad(session: Session, client: TestClient): 29 + def test_help_command(session: Session, client: TestClient): 30 30 """Test command /kefi help""" 31 31 32 32 response = client.post("/command/", data=generate_command(text="help")) ··· 38 38 assert len(data["blocks"]) == 4 39 39 40 40 41 - def test_wallet_commad(session: Session, client: TestClient): 41 + def test_wallet_command(session: Session, client: TestClient): 42 42 """Test command /kefi wallet""" 43 43 44 44 user = User(slack_user_id="U1")
+192
kefi/tests/test_interactivity.py
··· 1 + import json 2 + 3 + from fastapi.testclient import TestClient 4 + from pytest_mock import MockerFixture 5 + from slack_sdk.web.client import WebClient 6 + 7 + 8 + def test_default_interactivity(client: TestClient, mocker: MockerFixture): 9 + views_open = mocker.patch.object(WebClient, "views_open") 10 + views_open_response: dict = {} # Adds expected response here 11 + views_open.side_effect = [views_open_response] 12 + response = client.post( 13 + "/interactivity/", 14 + data={ 15 + "payload": json.dumps( 16 + { 17 + "type": "shortcut", 18 + "token": "7n993LRqLRydOnIkEhymVcYD", 19 + "action_ts": "1667577118.758775", 20 + "team": {"id": "T0RS507QX", "domain": "dekalabs"}, 21 + "user": { 22 + "id": "UCBKX2GQ4", 23 + "username": "marcos", 24 + "team_id": "T0RS507QX", 25 + }, 26 + "is_enterprise_install": False, 27 + "enterprise": None, 28 + "callback_id": "kefi_meets", 29 + "trigger_id": "4322087874981.25889007847.4301795f6ef2096cbc96f70c5d7bd48b", 30 + } 31 + ) 32 + }, 33 + ) 34 + assert response.status_code == 200 35 + 36 + 37 + def test_meet_join_view_submission(client: TestClient, mocker: MockerFixture): 38 + views_open = mocker.patch.object(WebClient, "views_open") 39 + views_open_response: dict = {} # Adds expected response here 40 + views_open.side_effect = [views_open_response] 41 + post_message_user = mocker.patch.object(WebClient, "chat_postMessage") 42 + post_message_user_response: dict = {} # Adds expected response here 43 + post_message_user.side_effect = [post_message_user_response] 44 + payload = json.dumps( 45 + { 46 + "type": "view_submission", 47 + "team": {"id": "T0RS507QX", "domain": "dekalabs"}, 48 + "user": { 49 + "id": "UCBKX2GQ4", 50 + "username": "marcos", 51 + "name": "marcos", 52 + "team_id": "T0RS507QX", 53 + }, 54 + "api_app_id": "A0499FQH7V3", 55 + "token": "7n993LRqLRydOnIkEhymVcYD", 56 + "trigger_id": "4338017757538.25889007847.877c730e79e8a4b1917de09d38a8daa0", 57 + "view": { 58 + "id": "V04AMLW580G", 59 + "team_id": "T0RS507QX", 60 + "type": "modal", 61 + "blocks": [ 62 + { 63 + "type": "section", 64 + "block_id": "9n+9", 65 + "text": { 66 + "type": "mrkdwn", 67 + "text": "*:wave: \\u00a1Hola! \\u00bfTe vienes a la Kefi Plaza?*", 68 + "verbatim": False, 69 + }, 70 + }, 71 + {"type": "divider", "block_id": "MSUNF"}, 72 + { 73 + "type": "section", 74 + "block_id": "IbY", 75 + "text": { 76 + "type": "mrkdwn", 77 + "text": "*Pr\\u00f3ximo encuentro*\\nFriday, 11 November\\n10:00\\n", 78 + "verbatim": False, 79 + }, 80 + "accessory": { 81 + "type": "image", 82 + "image_url": "https:\\/\\/api.slack.com\\/img\\/blocks\\/bkb_template_images\\/notifications.png", 83 + "alt_text": "calendar thumbnail", 84 + }, 85 + }, 86 + ], 87 + "private_metadata": "", 88 + "callback_id": "meet_join", 89 + "state": {"values": {}}, 90 + "hash": "1667896295.Ry45BkuV", 91 + "title": {"type": "plain_text", "text": "Kefi", "emoji": True}, 92 + "clear_on_close": False, 93 + "notify_on_close": False, 94 + "close": {"type": "plain_text", "text": "Cancelar", "emoji": True}, 95 + "submit": { 96 + "type": "plain_text", 97 + "text": "\\u00a1Me apunto!", 98 + "emoji": True, 99 + }, 100 + "previous_view_id": None, 101 + "root_view_id": "V04AMLW580G", 102 + "app_id": "A0499FQH7V3", 103 + "external_id": "", 104 + "app_installed_team_id": "T0RS507QX", 105 + "bot_id": "B0495T0Q0S2", 106 + }, 107 + "response_urls": [], 108 + "is_enterprise_install": False, 109 + "enterprise": None, 110 + } 111 + ) 112 + response = client.post("/interactivity/", data={"payload": payload}) 113 + assert response.status_code == 200 114 + 115 + 116 + def test_meet_leave_view_submission(client: TestClient, mocker: MockerFixture): 117 + views_open = mocker.patch.object(WebClient, "views_open") 118 + views_open_response: dict = {} # Adds expected response here 119 + views_open.side_effect = [views_open_response] 120 + post_message_user = mocker.patch.object(WebClient, "chat_postMessage") 121 + post_message_user_response: dict = {} # Adds expected response here 122 + post_message_user.side_effect = [post_message_user_response] 123 + payload = json.dumps( 124 + { 125 + "type": "view_submission", 126 + "team": {"id": "T0RS507QX", "domain": "dekalabs"}, 127 + "user": { 128 + "id": "UCBKX2GQ4", 129 + "username": "marcos", 130 + "name": "marcos", 131 + "team_id": "T0RS507QX", 132 + }, 133 + "api_app_id": "A0499FQH7V3", 134 + "token": "7n993LRqLRydOnIkEhymVcYD", 135 + "trigger_id": "4338017757538.25889007847.877c730e79e8a4b1917de09d38a8daa0", 136 + "view": { 137 + "id": "V04AMLW580G", 138 + "team_id": "T0RS507QX", 139 + "type": "modal", 140 + "blocks": [ 141 + { 142 + "type": "section", 143 + "block_id": "9n+9", 144 + "text": { 145 + "type": "mrkdwn", 146 + "text": "*:wave: \\u00a1Hola! \\u00bfTe vienes a la Kefi Plaza?*", 147 + "verbatim": False, 148 + }, 149 + }, 150 + {"type": "divider", "block_id": "MSUNF"}, 151 + { 152 + "type": "section", 153 + "block_id": "IbY", 154 + "text": { 155 + "type": "mrkdwn", 156 + "text": "*Pr\\u00f3ximo encuentro*\\nFriday, 11 November\\n10:00\\n", 157 + "verbatim": False, 158 + }, 159 + "accessory": { 160 + "type": "image", 161 + "image_url": "https:\\/\\/api.slack.com\\/img\\/blocks\\/bkb_template_images\\/notifications.png", 162 + "alt_text": "calendar thumbnail", 163 + }, 164 + }, 165 + ], 166 + "private_metadata": "", 167 + "callback_id": "meet_leave", 168 + "state": {"values": {}}, 169 + "hash": "1667896295.Ry45BkuV", 170 + "title": {"type": "plain_text", "text": "Kefi", "emoji": True}, 171 + "clear_on_close": False, 172 + "notify_on_close": False, 173 + "close": {"type": "plain_text", "text": "Cancelar", "emoji": True}, 174 + "submit": { 175 + "type": "plain_text", 176 + "text": "\\u00a1Me apunto!", 177 + "emoji": True, 178 + }, 179 + "previous_view_id": None, 180 + "root_view_id": "V04AMLW580G", 181 + "app_id": "A0499FQH7V3", 182 + "external_id": "", 183 + "app_installed_team_id": "T0RS507QX", 184 + "bot_id": "B0495T0Q0S2", 185 + }, 186 + "response_urls": [], 187 + "is_enterprise_install": False, 188 + "enterprise": None, 189 + } 190 + ) 191 + response = client.post("/interactivity/", data={"payload": payload}) 192 + assert response.status_code == 200
+65 -3
kefi/tests/test_models.py
··· 1 + import datetime 2 + 3 + import pytz 1 4 from sqlmodel import Session 2 5 3 6 from kefi.config import settings 4 - from kefi.models.database import Action, Transaction, User 5 - from kefi.models.helpers import ( 7 + from kefi.models.core.exceptions import NotEnoughKefi 8 + from kefi.models.core.helpers import ( 6 9 available_balance, 7 10 received_balance, 8 11 recharge_wallets, 9 12 reset_wallets, 10 - send_action, 11 13 ) 14 + from kefi.models.database import Action, Attendance, Plaza, Transaction, User 15 + from kefi.models.kudos.helpers import send_action 16 + from kefi.models.plazas.exceptions import AlreadyAttending 17 + from kefi.models.plazas.helpers import create_attendance, get_or_create_current_plaza 12 18 13 19 14 20 def test_available_balance(session: Session): ··· 86 92 available_balance(user=user, session=session) 87 93 == settings.RECHARGE_KEFIS_AMOUNT 88 94 ) 95 + 96 + 97 + def test_get_or_create_current_plaza(session: Session): 98 + first_plaza = get_or_create_current_plaza(session=session) 99 + assert isinstance(first_plaza, Plaza) 100 + second_plaza = get_or_create_current_plaza(session=session) 101 + assert isinstance(second_plaza, Plaza) 102 + assert first_plaza.id == second_plaza.id 103 + 104 + 105 + def test_get_or_create_current_plaza_with_future_and_past(session: Session): 106 + first_plaza = get_or_create_current_plaza(session=session) 107 + session.add(Plaza(date=first_plaza.date + datetime.timedelta(days=3))) 108 + session.add( 109 + Plaza(date=datetime.datetime.now(tz=pytz.utc) - datetime.timedelta(days=3)) 110 + ) 111 + assert isinstance(first_plaza, Plaza) 112 + second_plaza = get_or_create_current_plaza(session=session) 113 + assert isinstance(second_plaza, Plaza) 114 + assert first_plaza.id == second_plaza.id 115 + 116 + 117 + def test_create_attendance_no_balance(session: Session): 118 + user = User(slack_user_id="user_1") 119 + session.add(user) 120 + try: 121 + create_attendance(user=user, session=session) 122 + exception = False 123 + except NotEnoughKefi: 124 + exception = True 125 + assert exception 126 + 127 + 128 + def test_create_attendance(session: Session): 129 + user = User(slack_user_id="user_1") 130 + session.add(user) 131 + recharge_wallets(settings.RECHARGE_KEFIS_AMOUNT, session) 132 + initial_balance = available_balance(user=user, session=session) 133 + attendance = create_attendance(user=user, session=session) 134 + assert isinstance(attendance, Attendance) 135 + assert initial_balance - settings.PLAZA_PRICE == available_balance( 136 + user=user, session=session 137 + ) 138 + 139 + 140 + def test_create_attendance_already_existing(session: Session): 141 + user = User(slack_user_id="user_1") 142 + session.add(user) 143 + recharge_wallets(settings.RECHARGE_KEFIS_AMOUNT, session) 144 + create_attendance(user=user, session=session) 145 + try: 146 + create_attendance(user=user, session=session) 147 + exception = False 148 + except AlreadyAttending: 149 + exception = True 150 + assert exception
+1 -1
kefi/tests/test_responses.py
··· 4 4 5 5 from kefi.constants import Command 6 6 from kefi.models.database import Action, User 7 - from kefi.models.helpers import create_default_actions 7 + from kefi.models.kudos.helpers import create_default_actions 8 8 from kefi.routers.responses import ActionResponse 9 9 10 10
+2 -2
kefi/tests/test_slack.py
··· 9 9 def test_get_users(mocker: MockerFixture): 10 10 slack = Slack() 11 11 users_list_mock = mocker.patch.object(WebClient, "users_list") 12 - first_response = { 12 + first_response: dict[str, list | dict] = { 13 13 "members": [{"name": "merkos"}, {"name": "rubon"}, {"name": "sondra"}], 14 14 "response_metadata": {"next_cursor": "next_cursor"}, 15 15 } 16 - second_response = { 16 + second_response: dict[str, list | dict] = { 17 17 "members": [ 18 18 {"name": "ivo"}, 19 19 {"name": "marina"},
+16 -3
manage.py
··· 1 1 #!/usr/bin/env python 2 - from typing import Dict 2 + import locale 3 3 4 4 from IPython import start_ipython 5 5 6 6 7 - def kefi_namespace() -> Dict: 7 + def kefi_namespace() -> dict: 8 8 """Defines a dict with the scope to use in the shell. By default we add: 9 9 - Models 10 10 - Session ··· 13 13 from sqlmodel import Session, select 14 14 15 15 from kefi.config import settings 16 - from kefi.models.database import Action, Transaction, User, engine 16 + from kefi.models.database import ( 17 + Action, 18 + Attendance, 19 + Plaza, 20 + Transaction, 21 + User, 22 + engine, 23 + ) 17 24 25 + try: 26 + locale.setlocale(locale.LC_TIME, settings.LOCALE) 27 + except locale.Error: 28 + ... 18 29 session = Session(engine) 19 30 return { 20 31 "Action": Action, 21 32 "Transaction": Transaction, 33 + "Plaza": Plaza, 34 + "Attendance": Attendance, 22 35 "User": User, 23 36 "settings": settings, 24 37 "engine": engine,
+63 -33
poetry.lock
··· 32 32 speedups = ["aiodns", "brotli", "cchardet"] 33 33 34 34 [[package]] 35 - name = "aioredis" 36 - version = "1.3.1" 37 - description = "asyncio (PEP 3156) Redis support" 38 - category = "main" 39 - optional = false 40 - python-versions = "*" 41 - 42 - [package.dependencies] 43 - async-timeout = "*" 44 - hiredis = "*" 45 - 46 - [[package]] 47 35 name = "aiosignal" 48 36 version = "1.2.0" 49 37 description = "aiosignal: a list of registered asynchronous callbacks" ··· 96 84 97 85 [[package]] 98 86 name = "arq" 99 - version = "0.22" 87 + version = "0.24.0" 100 88 description = "Job queues in python with asyncio and redis" 101 89 category = "main" 102 90 optional = false 103 - python-versions = ">=3.6" 91 + python-versions = ">=3.7" 104 92 105 93 [package.dependencies] 106 - aioredis = ">=1.1.0,<2.0.0" 107 - click = ">=6.7" 108 - pydantic = ">=1" 94 + click = ">=8.0" 95 + redis = {version = ">=4.2.0", extras = ["hiredis"]} 96 + typing-extensions = ">=4.1.0" 109 97 110 98 [package.extras] 111 - watch = ["watchgod (>=0.4)"] 99 + watch = ["watchfiles (>=0.16)"] 112 100 113 101 [[package]] 114 102 name = "astroid" ··· 276 264 python-versions = ">=3.5" 277 265 278 266 [[package]] 267 + name = "deprecated" 268 + version = "1.2.13" 269 + description = "Python @deprecated decorator to deprecate old python classes, functions or methods." 270 + category = "main" 271 + optional = false 272 + python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 273 + 274 + [package.dependencies] 275 + wrapt = ">=1.10,<2" 276 + 277 + [package.extras] 278 + dev = ["tox", "bump2version (<1)", "sphinx (<2)", "importlib-metadata (<3)", "importlib-resources (<4)", "configparser (<5)", "sphinxcontrib-websupport (<2)", "zipp (<2)", "PyTest (<5)", "PyTest-Cov (<2.6)", "pytest", "pytest-cov"] 279 + 280 + [[package]] 279 281 name = "dill" 280 282 version = "0.3.6" 281 283 description = "serialize all of python" ··· 296 298 297 299 [[package]] 298 300 name = "exceptiongroup" 299 - version = "1.0.0" 301 + version = "1.0.1" 300 302 description = "Backport of PEP 654 (exception groups)" 301 303 category = "dev" 302 304 optional = false ··· 381 383 382 384 [[package]] 383 385 name = "greenlet" 384 - version = "2.0.0" 386 + version = "2.0.0.post0" 385 387 description = "Lightweight in-process concurrent programming" 386 388 category = "main" 387 389 optional = false ··· 606 608 name = "packaging" 607 609 version = "21.3" 608 610 description = "Core utilities for Python packages" 609 - category = "dev" 611 + category = "main" 610 612 optional = false 611 613 python-versions = ">=3.6" 612 614 ··· 694 696 695 697 [[package]] 696 698 name = "prompt-toolkit" 697 - version = "3.0.31" 699 + version = "3.0.32" 698 700 description = "Library for building powerful interactive command lines in Python" 699 701 category = "main" 700 702 optional = false ··· 817 819 name = "pyparsing" 818 820 version = "3.0.9" 819 821 description = "pyparsing module - Classes and methods to define and execute parsing grammars" 820 - category = "dev" 822 + category = "main" 821 823 optional = false 822 824 python-versions = ">=3.6.8" 823 825 ··· 948 950 six = ">=1.4.0" 949 951 950 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]] 951 961 name = "pyyaml" 952 962 version = "6.0" 953 963 description = "YAML parser and emitter for Python" ··· 956 966 python-versions = ">=3.6" 957 967 958 968 [[package]] 969 + name = "redis" 970 + version = "4.3.4" 971 + description = "Python client for Redis database and key-value store" 972 + category = "main" 973 + optional = false 974 + python-versions = ">=3.6" 975 + 976 + [package.dependencies] 977 + async-timeout = ">=4.0.2" 978 + deprecated = ">=1.2.3" 979 + hiredis = {version = ">=1.0.0", optional = true, markers = "extra == \"hiredis\""} 980 + packaging = ">=20.4" 981 + 982 + [package.extras] 983 + hiredis = ["hiredis (>=1.0.0)"] 984 + ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] 985 + 986 + [[package]] 959 987 name = "requests" 960 988 version = "2.28.1" 961 989 description = "Python HTTP for Humans." ··· 1241 1269 name = "wrapt" 1242 1270 version = "1.14.1" 1243 1271 description = "Module for decorators, wrappers and monkey patching." 1244 - category = "dev" 1272 + category = "main" 1245 1273 optional = false 1246 1274 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 1247 1275 ··· 1260 1288 [metadata] 1261 1289 lock-version = "1.1" 1262 1290 python-versions = "^3.10" 1263 - content-hash = "3de1ef8fa7f1fa256f2923864a5ce3adc1f26678d8c3a4d1de284df38749e8aa" 1291 + content-hash = "8e47557b60b0276276fafe2e8917adc19d5c353755f7bc00d80424a0fa618ed0" 1264 1292 1265 1293 [metadata.files] 1266 1294 aiodns = [ ··· 1268 1296 {file = "aiodns-3.0.0.tar.gz", hash = "sha256:946bdfabe743fceeeb093c8a010f5d1645f708a241be849e17edfb0e49e08cd6"}, 1269 1297 ] 1270 1298 aiohttp = [] 1271 - aioredis = [ 1272 - {file = "aioredis-1.3.1-py3-none-any.whl", hash = "sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3"}, 1273 - {file = "aioredis-1.3.1.tar.gz", hash = "sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a"}, 1274 - ] 1275 1299 aiosignal = [ 1276 1300 {file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"}, 1277 1301 {file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"}, ··· 1282 1306 {file = "appnope-0.1.3-py2.py3-none-any.whl", hash = "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"}, 1283 1307 {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"}, 1284 1308 ] 1285 - arq = [ 1286 - {file = "arq-0.22-py3-none-any.whl", hash = "sha256:55a0f933636c804b82c366a0e3710e9e5ed26a716251fa6742777d0b039f7f30"}, 1287 - {file = "arq-0.22.tar.gz", hash = "sha256:c7bd98151cc83cec941ce5f660ede4bee888effd9a4692258ec8a9a0aff2f9f9"}, 1288 - ] 1309 + arq = [] 1289 1310 astroid = [] 1290 1311 async-timeout = [ 1291 1312 {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, ··· 1428 1449 decorator = [ 1429 1450 {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, 1430 1451 {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, 1452 + ] 1453 + deprecated = [ 1454 + {file = "Deprecated-1.2.13-py2.py3-none-any.whl", hash = "sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d"}, 1455 + {file = "Deprecated-1.2.13.tar.gz", hash = "sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d"}, 1431 1456 ] 1432 1457 dill = [] 1433 1458 distlib = [] ··· 1702 1727 python-multipart = [ 1703 1728 {file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"}, 1704 1729 ] 1730 + pytz = [] 1705 1731 pyyaml = [ 1706 1732 {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, 1707 1733 {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, ··· 1736 1762 {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, 1737 1763 {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, 1738 1764 {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, 1765 + ] 1766 + redis = [ 1767 + {file = "redis-4.3.4-py3-none-any.whl", hash = "sha256:a52d5694c9eb4292770084fa8c863f79367ca19884b329ab574d5cb2036b3e54"}, 1768 + {file = "redis-4.3.4.tar.gz", hash = "sha256:ddf27071df4adf3821c4f2ca59d67525c3a82e5f268bed97b813cb4fabf87880"}, 1739 1769 ] 1740 1770 requests = [ 1741 1771 {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"},
+4 -3
pyproject.toml
··· 1 1 [tool.poetry] 2 2 name = "Kefi" 3 - version = "1.0.0" 3 + version = "2.0.0" 4 4 description = "An awesome project from Dekalabs" 5 5 authors = ["Deka <backend@dekalabs.com>"] 6 6 ··· 16 16 python-dotenv = "^0.21.0" 17 17 ipython = "^7.29.0" 18 18 requests = "^2.26.0" 19 - arq = "^0.22" 20 - alembic = "^1.7.4" 19 + arq = "^0.24" 20 + alembic = "^1.8.1" 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"