├── bot ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0004_auto_20230406_1938.py │ ├── 0005_auto_20230406_2002.py │ ├── 0003_telegramgroup_name.py │ ├── 0002_telegramgroup.py │ └── 0001_initial.py ├── tests.py ├── apps.py ├── admin.py ├── exceptions.py ├── management │ └── commands │ │ └── startbot.py ├── bot.py ├── models.py ├── help_texts.py ├── handlers.py └── utils.py ├── expenses ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0005_auto_20190402_2031.py │ ├── 0010_auto_20190421_1549.py │ ├── 0003_auto_20190323_1615.py │ ├── 0009_auto_20190419_1353.py │ ├── 0002_auto_20190319_0324.py │ ├── 0007_auto_20190402_2149.py │ ├── 0006_tag_group.py │ ├── 0004_auto_20190402_2027.py │ ├── 0008_auto_20190419_1350.py │ ├── 0011_payment.py │ └── 0001_initial.py ├── tests.py ├── views.py ├── apps.py ├── admin.py └── models.py ├── gastitis ├── __init__.py ├── urls.py ├── exceptions.py ├── wsgi.py └── settings.py ├── runtime.txt ├── media ├── avatar.png └── avatar_test.png ├── requirements.txt ├── app.json ├── manage.py ├── docs ├── bot_manual_settings.md ├── functional_testing.md ├── create_new_command.md ├── keep_bot_running_in_server.md └── database_setup.md ├── LICENSE ├── extra_features ├── asado.py └── vianda.py ├── .gitignore ├── services └── google_sheets.py ├── use_cases └── export.py └── README.md /bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /expenses/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gastitis/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bot/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.6.6 2 | -------------------------------------------------------------------------------- /expenses/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofide/gastitis/HEAD/media/avatar.png -------------------------------------------------------------------------------- /bot/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /expenses/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /expenses/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /media/avatar_test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofide/gastitis/HEAD/media/avatar_test.png -------------------------------------------------------------------------------- /bot/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BotConfig(AppConfig): 5 | name = 'bot' 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==5.1.4 2 | gspread==6.1.4 3 | psycopg2-binary==2.9.10 4 | python-telegram-bot==21.9 5 | -------------------------------------------------------------------------------- /expenses/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ExpensesConfig(AppConfig): 5 | name = 'expenses' 6 | -------------------------------------------------------------------------------- /gastitis/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include 3 | 4 | urlpatterns = [ 5 | path('admin/', admin.site.urls), 6 | ] 7 | -------------------------------------------------------------------------------- /bot/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from bot.models import TelegramGroup, TelegramUser 4 | 5 | # Register your models here. 6 | admin.site.register(TelegramGroup) 7 | admin.site.register(TelegramUser) 8 | -------------------------------------------------------------------------------- /bot/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom exceptions for bot managing. 3 | """ 4 | 5 | class ParameterError(Exception): 6 | """ 7 | Error in parameters received in a command. 8 | """ 9 | pass 10 | 11 | 12 | class DateFormatterError(Exception): 13 | """ 14 | Error in date format 15 | """ 16 | pass 17 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Gastitis", 3 | "description": "Telegram as a tool to track your expenses", 4 | "repository": "https://github.com/sofide/gastitis", 5 | "keywords": ["expenses", "django", "python"], 6 | "env": { 7 | "HEROKU": { 8 | "description": "Indicates the app is running on Heroku", 9 | "value": "True" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /bot/management/commands/startbot.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from bot.bot import Bot 4 | 5 | 6 | class Command(BaseCommand): 7 | help = 'Start testing bot.' 8 | 9 | def handle(self, *args, **options): 10 | self.stdout.write(self.style.WARNING('Starting bot...')) 11 | Bot() 12 | self.stdout.write(self.style.SUCCESS('Bot started')) 13 | -------------------------------------------------------------------------------- /expenses/migrations/0005_auto_20190402_2031.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-04-02 20:31 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('expenses', '0004_auto_20190402_2027'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameModel( 14 | old_name='Category', 15 | new_name='Tag', 16 | ), 17 | ] 18 | atomic = False 19 | -------------------------------------------------------------------------------- /expenses/migrations/0010_auto_20190421_1549.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-04-21 15:49 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('expenses', '0009_auto_20190419_1353'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='exchangerate', 15 | options={'ordering': ['date']}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /gastitis/exceptions.py: -------------------------------------------------------------------------------- 1 | class NoExpensesInChat(Exception): 2 | """ 3 | No expenses created yet in current chat. 4 | """ 5 | 6 | 7 | class UserNotAuthorized(Exception): 8 | """ 9 | The user is not authorized to perform this action. 10 | 11 | Commonly used for beta commands. 12 | """ 13 | 14 | 15 | class GoogleAPIConnectionError(Exception): 16 | """ 17 | Credentials are not defined or there is another error with Google API 18 | """ 19 | -------------------------------------------------------------------------------- /gastitis/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for gastitis project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'gastitis.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /expenses/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from expenses.models import Expense, Tag, Division, ExchangeRate 4 | 5 | 6 | admin.site.register(Tag) 7 | admin.site.register(Division) 8 | 9 | 10 | @admin.register(Expense) 11 | class ExpenseAdmin(admin.ModelAdmin): 12 | list_display = ('date', 'amount', 'group', 'description') 13 | 14 | 15 | @admin.register(ExchangeRate) 16 | class ExchangeRateAdmin(admin.ModelAdmin): 17 | list_display = ('date', 'currency', 'rate') 18 | -------------------------------------------------------------------------------- /bot/migrations/0004_auto_20230406_1938.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.13 on 2023-04-06 19:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('bot', '0003_telegramgroup_name'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='telegramuser', 15 | name='chat_id', 16 | field=models.BigIntegerField(), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bot/migrations/0005_auto_20230406_2002.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.13 on 2023-04-06 20:02 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('bot', '0004_auto_20230406_1938'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='telegramgroup', 15 | name='chat_id', 16 | field=models.BigIntegerField(), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /expenses/migrations/0003_auto_20190323_1615.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-03-23 16:15 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('expenses', '0002_auto_20190319_0324'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='expense', 15 | name='date', 16 | field=models.DateField(auto_now=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bot/bot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Initialize telegram bot and start polling messages. 3 | """ 4 | 5 | from django.conf import settings 6 | from telegram.ext import Application 7 | 8 | from bot.handlers import HANDLERS 9 | 10 | 11 | class Bot: 12 | def __init__(self, token=settings.BOT_TOKEN): 13 | self.application = Application.builder().token(token).build() 14 | 15 | for handler in HANDLERS: 16 | self.application.add_handler(handler) 17 | 18 | self.application.run_polling() 19 | -------------------------------------------------------------------------------- /expenses/migrations/0009_auto_20190419_1353.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-04-19 13:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('expenses', '0008_auto_20190419_1350'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='expense', 15 | name='created_date', 16 | field=models.DateTimeField(auto_now=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bot/migrations/0003_telegramgroup_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-04-02 21:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('bot', '0002_telegramgroup'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='telegramgroup', 15 | name='name', 16 | field=models.CharField(default='initial', max_length=256), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /expenses/migrations/0002_auto_20190319_0324.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-03-19 03:24 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('expenses', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='category', 15 | options={'ordering': ['name']}, 16 | ), 17 | migrations.AlterModelOptions( 18 | name='expense', 19 | options={'ordering': ['-date', 'amount']}, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == '__main__': 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'gastitis.settings') 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /expenses/migrations/0007_auto_20190402_2149.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-04-02 21:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('expenses', '0006_tag_group'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='expense', 15 | name='category', 16 | ), 17 | migrations.AddField( 18 | model_name='expense', 19 | name='tags', 20 | field=models.ManyToManyField(related_name='expenses', to='expenses.Tag'), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /bot/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class TelegramUser(models.Model): 5 | """ 6 | Extend django user with telegram user data. 7 | """ 8 | user = models.OneToOneField('auth.User', on_delete=models.CASCADE, related_name='telegram') 9 | chat_id = models.BigIntegerField() 10 | username = models.CharField(max_length=256, blank=True) 11 | 12 | 13 | class TelegramGroup(models.Model): 14 | users = models.ManyToManyField('auth.User', related_name='telegram_groups') 15 | chat_id = models.BigIntegerField() 16 | name = models.CharField(max_length=256) 17 | 18 | def __str__(self): 19 | return self.name 20 | -------------------------------------------------------------------------------- /expenses/migrations/0006_tag_group.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-04-02 20:34 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('bot', '0002_telegramgroup'), 11 | ('expenses', '0005_auto_20190402_2031'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='tag', 17 | name='group', 18 | field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='bot.TelegramGroup'), 19 | preserve_default=False, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /docs/bot_manual_settings.md: -------------------------------------------------------------------------------- 1 | # Manual settings for gastitis Bot 2 | 3 | ## Commands list 4 | 5 | This command list should be setted in BotFather to show the available commands when 6 | a user types `/` or press the `[/]` button. 7 | ``` 8 | gasto - Cargar un gasto 9 | g - Cargar gasto 10 | pago - Cargar un pago 11 | p - Cargar un pago 12 | mes - Ver los gastos de un mes 13 | m - Ver los gastos de un mes 14 | total - Ver total de gastos 15 | asado - Calcular cuanto asado comprar 16 | a - Calcular cuanto asado comprar 17 | help - Mostrar ayuda 18 | ``` 19 | 20 | 21 | ## Profile pic 22 | The profile pic should also be configured with BotFather. 23 | You can find the images for Gastitis and Gastitis testing in the media folder. 24 | -------------------------------------------------------------------------------- /docs/functional_testing.md: -------------------------------------------------------------------------------- 1 | # Functional testing 2 | 3 | Here are a list of things that should be manually tested after big refactors. 4 | 5 | ## In a direct message with Gastitis: 6 | - `/total` should show the total expenses so far. 7 | - `/m` should show the total expenses for this month. 8 | - `/g` should save a new expense. 9 | - `/total` and `/m` should show the total expenses updated. 10 | 11 | 12 | ## In a group with Gastitis and other people: 13 | - `/total` should show the total expenses so far and the debt between the users. 14 | - `/m` should show the total expenses for this month. 15 | - `/g` should save a new expense. 16 | - `/p` should save a new payment. 17 | - `/total` and `/m` should show the total expenses updated. 18 | -------------------------------------------------------------------------------- /docs/create_new_command.md: -------------------------------------------------------------------------------- 1 | # Create a New Command 2 | 3 | There are a few steps to follow when creating a new command. 4 | 5 | ## Create the Command and Add the Respective Handler 6 | 7 | First, add your code to `bot/handlers.py` and register your new command in the `HANDLERS` constant at the end of the file. 8 | 9 | ## Update the Help Command 10 | 11 | You must also update the `/help` command to include the new command. 12 | 13 | ## Add the Command to the Command List 14 | 15 | You need to add the command in the [Bot Manual Settings](/bot_manual_settings.md) and set up the full, updated command 16 | list in your Telegram bot (by talking to BotFather). 17 | 18 | ## (Optional) Add Instructions to Functional Test the New Command 19 | 20 | Edit the [Functional Testing](/functional_testing.md) to include the new command. 21 | -------------------------------------------------------------------------------- /bot/migrations/0002_telegramgroup.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-04-02 20:27 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ('bot', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='TelegramGroup', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('chat_id', models.IntegerField()), 20 | ('users', models.ManyToManyField(related_name='telegram_groups', to=settings.AUTH_USER_MODEL)), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /bot/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-03-23 15:00 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='TelegramUser', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('chat_id', models.IntegerField()), 22 | ('username', models.CharField(blank=True, max_length=256)), 23 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='telegram', to=settings.AUTH_USER_MODEL)), 24 | ], 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /expenses/migrations/0004_auto_20190402_2027.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-04-02 20:27 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('bot', '0002_telegramgroup'), 12 | ('expenses', '0003_auto_20190323_1615'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='expense', 18 | name='group', 19 | field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='expenses', to='bot.TelegramGroup'), 20 | preserve_default=False, 21 | ), 22 | migrations.AlterField( 23 | model_name='expense', 24 | name='user', 25 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expenses', to=settings.AUTH_USER_MODEL), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sofía Denner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /extra_features/asado.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | 4 | # map asado items to the calc of how much to buy for each of them 5 | ITEMS_TO_BUY = { 6 | "choris": lambda people: math.ceil(0.5 * people), 7 | "morcillas": lambda people: math.ceil(0.5 * people), 8 | "churrasquitos de cerdo": lambda people: math.ceil(0.33 * people), 9 | "kg de costilla": lambda people: round(0.2 * people, 1), 10 | "kg de vacío": lambda people: round(0.2 * people, 1), 11 | } 12 | 13 | def how_much_asado_message(people: int): 14 | """ 15 | Calculate how much asado to buy for the given number of people and create 16 | a friendly message. 17 | 18 | args: 19 | people (int): quantity of people that will eat the asado 20 | 21 | returns: 22 | str: message indicating how much asado to buy 23 | """ 24 | if people <= 2: 25 | return "Este bot sabe calcular asado para 3 o más personas" 26 | 27 | message = f"Lista de compra para {people} personas:\n" 28 | 29 | for what_to_buy, how_much in ITEMS_TO_BUY.items(): 30 | message += f"- {how_much(people)} {what_to_buy}\n" 31 | 32 | return message 33 | -------------------------------------------------------------------------------- /expenses/migrations/0008_auto_20190419_1350.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-04-19 13:50 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('expenses', '0007_auto_20190402_2149'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='ExchangeRate', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('currency', models.CharField(choices=[('u', 'usd'), ('y', 'yen')], max_length=1)), 18 | ('rate', models.DecimalField(decimal_places=4, max_digits=10)), 19 | ('date', models.DateField()), 20 | ], 21 | ), 22 | migrations.AddField( 23 | model_name='expense', 24 | name='original_amount', 25 | field=models.DecimalField(decimal_places=2, max_digits=256, null=True), 26 | ), 27 | migrations.AddField( 28 | model_name='expense', 29 | name='original_currency', 30 | field=models.CharField(choices=[('u', 'usd'), ('y', 'yen')], max_length=1, null=True), 31 | ), 32 | migrations.AlterField( 33 | model_name='expense', 34 | name='date', 35 | field=models.DateField(), 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /expenses/migrations/0011_payment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-06-17 04:30 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('bot', '0003_telegramgroup_name'), 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ('expenses', '0010_auto_20190421_1549'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Payment', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('amount', models.DecimalField(decimal_places=2, max_digits=256)), 22 | ('date', models.DateField()), 23 | ('created_date', models.DateTimeField(auto_now=True)), 24 | ('from_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments_done', to=settings.AUTH_USER_MODEL)), 25 | ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='bot.TelegramGroup')), 26 | ('to_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments_recived', to=settings.AUTH_USER_MODEL)), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /expenses/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-03-19 03:13 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Category', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('name', models.CharField(max_length=256)), 22 | ], 23 | ), 24 | migrations.CreateModel( 25 | name='Division', 26 | fields=[ 27 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 28 | ('portion', models.FloatField()), 29 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 30 | ], 31 | ), 32 | migrations.CreateModel( 33 | name='Expense', 34 | fields=[ 35 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 36 | ('description', models.TextField()), 37 | ('amount', models.DecimalField(decimal_places=2, max_digits=256)), 38 | ('date', models.DateField()), 39 | ('created_date', models.DateField(auto_now=True)), 40 | ('category', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='expenses.Category')), 41 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 42 | ], 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /bot/help_texts.py: -------------------------------------------------------------------------------- 1 | """ 2 | Texts for /help command. 3 | """ 4 | OPTIONAL_PARAMETERS = "_Parámetros opcionales:_" 5 | SHORTCUT = "_Atajo_:" 6 | EXAMPLE = "_Ejemplo_:" 7 | 8 | HELP_GASTO = f""" 9 | *Registrar un gasto:* `/gasto ` 10 | 11 | {SHORTCUT} `/g` 12 | 13 | {OPTIONAL_PARAMETERS} 14 | - dd en formato año/mes/día. Ej: `dd 2024/10/01`. 15 | - tt tag1,tag2,tag3. Ej: `tt comida` o `tt comida,salida`. 16 | - uu que realizó el gasto. Ej: `uu juan`. (por default se carga el gasto a nombre de quien envía el mensaje). 17 | _Nota:_ El usuario tiene que haber enviado previamente algún comando en el grupo para que gastitis lo registre. 18 | 19 | Ejemplo con todos los params opcionales: 20 | `/gasto 10 pan dd 2024/01/30 tt comida uu juan` 21 | """ 22 | 23 | HELP_TOTAL = """ 24 | *Ver el total hasta el momento:* `/total` 25 | """ 26 | 27 | HELP_MES = f""" 28 | *Ver el total del mes actual:* `/mes` 29 | 30 | {SHORTCUT} `/m` 31 | 32 | {OPTIONAL_PARAMETERS} 33 | - `/mes ` Ej: `/mes 5` para ver los gastos de mayo del año actual. 34 | - `/mes ` para ver los gastos del mes/año especificado. 35 | """ 36 | 37 | HELP_PAGO = f""" 38 | *Registrar un pago a un usuario del grupo:* `/pago ` 39 | 40 | _Esta funcionalidad solo está disponible en grupos con más de un usuario._ 41 | 42 | _Nota:_ El usuario tiene que haber enviado previamente algún comando en el grupo para que gastitis lo registre. 43 | 44 | {SHORTCUT} `/p` 45 | 46 | {OPTIONAL_PARAMETERS} 47 | - Ej: `/pago @juan 2020/12/1` 48 | """ 49 | 50 | HELP_ASADO = f""" 51 | *Calcular asado a comprar según la cantidad de personas:* `/asado ` 52 | 53 | {SHORTCUT} `/a` 54 | 55 | {EXAMPLE} `/asado 12` Para calcular cuánto asado comprar para 12 personas. 56 | """ 57 | 58 | HELP_TEXT = f"""Cómo usar gastitis: 59 | 60 | {HELP_GASTO} 61 | -------------------- 62 | {HELP_TOTAL} 63 | -------------------- 64 | {HELP_MES} 65 | -------------------- 66 | {HELP_PAGO} 67 | -------------------- 68 | {HELP_ASADO} 69 | """ 70 | -------------------------------------------------------------------------------- /docs/keep_bot_running_in_server.md: -------------------------------------------------------------------------------- 1 | # Keep bot runnin in server 2 | 3 | This is a guide to use supervisorctl to keep the bot running. 4 | 5 | ## 1. Install supervisorctl 6 | 7 | Run the following command to install Supervisor: 8 | 9 | ```bash 10 | sudo apt update 11 | sudo apt install supervisor 12 | ``` 13 | 14 | ## 2. Create a Configuration File for Your Program 15 | Supervisor configuration files are typically stored in /etc/supervisor/conf.d/. 16 | Create a new configuration file for your program, e.g., gastitis.conf: 17 | 18 | ```bash 19 | sudo nano /etc/supervisor/conf.d/gastitis.conf 20 | ``` 21 | 22 | ## 3. Add Configuration for Your Program 23 | Add the following content to the configuration file, replacing placeholders with your program's details: 24 | 25 | ``` 26 | [program:gastitis] 27 | command=/bin/python manage.py startbot 28 | directory= 29 | autostart=true # Start the program when Supervisor starts 30 | autorestart=true # Restart the program if it exits unexpectedly 31 | stderr_logfile=/var/log/gastitis.err.log # Log for standard error 32 | stdout_logfile=/var/log/gastitis.out.log # Log for standard output 33 | user= # The user under which to run the program 34 | ``` 35 | ## 4. Update Supervisor Configuration 36 | After saving the configuration file, update Supervisor to recognize the new program: 37 | 38 | ```bash 39 | sudo supervisorctl reread 40 | sudo supervisorctl update 41 | ``` 42 | 43 | ## 5. Start and Monitor Your Program 44 | Start your program using Supervisor: 45 | 46 | ```bash 47 | sudo supervisorctl start gastitis 48 | ``` 49 | 50 | You can check the program's status with: 51 | 52 | ```bash 53 | sudo supervisorctl status 54 | ``` 55 | 56 | ## 6. View Logs 57 | To debug or monitor the program's output, view the logs: 58 | 59 | ```bash 60 | tail -f /var/log/gastitis.out.log 61 | tail -f /var/log/gastitis.err.log 62 | ``` 63 | 64 | ## 7. Deploy new changes 65 | 66 | When you make changes to the code, you need to pull the latest changes from your repository. 67 | ```bash 68 | git pull origin master 69 | ``` 70 | 71 | Then, restart the bot: 72 | 73 | ```bash 74 | sudo supervisorctl restart gastitis 75 | ``` 76 | 77 | Check the bot is running correctly: 78 | 79 | ```bash 80 | sudo supervisorctl status 81 | ``` 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # pipenv 86 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 87 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 88 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 89 | # install all needed dependencies. 90 | #Pipfile.lock 91 | 92 | # celery beat schedule file 93 | celerybeat-schedule 94 | 95 | # SageMath parsed files 96 | *.sage.py 97 | 98 | # Environments 99 | .env 100 | .venv 101 | env/ 102 | venv/ 103 | ENV/ 104 | env.bak/ 105 | venv.bak/ 106 | 107 | # Spyder project settings 108 | .spyderproject 109 | .spyproject 110 | 111 | # Rope project settings 112 | .ropeproject 113 | 114 | # mkdocs documentation 115 | /site 116 | 117 | # mypy 118 | .mypy_cache/ 119 | .dmypy.json 120 | dmypy.json 121 | 122 | # Pyre type checker 123 | .pyre/ 124 | 125 | gastitis/secret_settings.py 126 | gastitis/google_credentials.json 127 | 128 | # mac stuff 129 | .DS_Store 130 | -------------------------------------------------------------------------------- /expenses/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from bot.models import TelegramGroup 4 | 5 | 6 | # Define accepted currency. Keys must have one character long. 7 | CURRENCY = { 8 | 'u': 'usd', 9 | 'y': 'yen', 10 | } 11 | 12 | 13 | class Tag(models.Model): 14 | """ 15 | Expenses tag, to keep track of grouped expenses, and compare them in different periods. 16 | """ 17 | name = models.CharField(max_length=256) 18 | group = models.ForeignKey(TelegramGroup, on_delete=models.CASCADE, related_name='tags') 19 | 20 | class Meta: 21 | ordering = ['name'] 22 | 23 | def __str__(self): 24 | return self.name 25 | 26 | 27 | class ExchangeRate(models.Model): 28 | """ 29 | Exchange rates for currencies different from default. 30 | """ 31 | currency = models.CharField(max_length=1, choices=CURRENCY.items()) 32 | rate = models.DecimalField(decimal_places=4, max_digits=10) 33 | date = models.DateField() 34 | 35 | class Meta: 36 | ordering = ['date'] 37 | 38 | 39 | class Expense(models.Model): 40 | user = models.ForeignKey('auth.User', on_delete=models.CASCADE, related_name='expenses') 41 | group = models.ForeignKey(TelegramGroup, on_delete=models.CASCADE, related_name='expenses') 42 | description = models.TextField() 43 | amount = models.DecimalField(decimal_places=2, max_digits=256) 44 | tags = models.ManyToManyField(Tag, related_name='expenses') 45 | date = models.DateField() 46 | created_date = models.DateTimeField(auto_now=True) 47 | 48 | # fields that represent an expense in a currency different from default. 49 | original_currency = models.CharField(max_length=1, choices=CURRENCY.items(), null=True) 50 | original_amount = models.DecimalField(decimal_places=2, max_digits=256, null=True) 51 | 52 | class Meta: 53 | ordering = ['-date', 'amount'] 54 | 55 | def __str__(self): 56 | return '{} - ${} - {}'.format(self.date, self.amount, self.description) 57 | 58 | 59 | class Division(models.Model): 60 | """ 61 | How much should everyone pay to afford the total expenses. 62 | """ 63 | user = models.ForeignKey('auth.User', on_delete=models.CASCADE) 64 | portion = models.FloatField() 65 | 66 | def __str__(self): 67 | return '{} - %{}'.format(self.user, self.portion) 68 | 69 | 70 | class Payment(models.Model): 71 | """ 72 | Payment from a user to another user in the same group. 73 | """ 74 | from_user = models.ForeignKey('auth.User', on_delete=models.CASCADE, 75 | related_name='payments_done') 76 | to_user = models.ForeignKey('auth.User', on_delete=models.CASCADE, 77 | related_name='payments_recived') 78 | group = models.ForeignKey(TelegramGroup, on_delete=models.CASCADE, related_name='payments') 79 | amount = models.DecimalField(decimal_places=2, max_digits=256) 80 | date = models.DateField() 81 | created_date = models.DateTimeField(auto_now=True) 82 | -------------------------------------------------------------------------------- /docs/database_setup.md: -------------------------------------------------------------------------------- 1 | # Database setup Guide 2 | 3 | Follow this guide to set up a databae in postgres container. 4 | 5 | ## Run a postgres container 6 | 7 | - To download latest image: 8 | ```bash 9 | docker pull postgres 10 | ``` 11 | 12 | - To see the downloaded image: 13 | ```bash 14 | docker images 15 | ``` 16 | 17 | - To run a container with a fixed volume in `postgres-datadir` 18 | ``` 19 | docker run -d \ 20 | --name postgres-sofi \ 21 | --restart unless-stopped \ 22 | -e POSTGRES_PASSWORD= \ 23 | -v :/var/lib/postgresql/data \ 24 | -v :/backups \ 25 | -p 5432:5432 \ 26 | postgres 27 | ``` 28 | 29 | - To access a shell inside postgres container, we can use psql: 30 | 31 | ```bash 32 | docker exec -it postgres-sofi psql -U postgres --help 33 | ``` 34 | 35 | The `-U postgres` param can be omited in the command `--help`, but it's needed in all 36 | the other commands to manipulate the database. 37 | 38 | You can use psql to create a new `gastitis` database or restore a backup. 39 | 40 | ## Check out existing databases 41 | ```bash 42 | docker exec -it postgres-sofi psql -U postgres -c "SELECT datname FROM pg_database" 43 | ``` 44 | 45 | ## Create a new database 46 | 47 | ```bash 48 | docker exec -it postgres-sofi psql -U postgres -c "CREATE DATABASE gastitis" 49 | ``` 50 | 51 | ## Restore a backup 52 | 53 | To restore a backup: 54 | 55 | - First, we need to make sure we have the correct volumes mounted. We can inspect that 56 | with the following command: 57 | 58 | ```bash 59 | docker inspect -f '{{ json .Mounts }}' postgres-sofi 60 | ``` 61 | 62 | - first, copy the dump in our `/backups` folder (thanks to the volume created in `run`) 63 | ```bash 64 | docker cp postgres-sofi:backups 65 | ``` 66 | 67 | - then, restore it with `pg_restore`: 68 | ```bash 69 | docker exec -it postgres-sofi pg_restore \ 70 | --verbose --clean --no-acl --no-owner \ 71 | -h localhost 72 | -U postgres 73 | -d gastitis \ 74 | backups/ 75 | ``` 76 | 77 | ### Make a Query 78 | 79 | To check everything is loaded as expected, we can run a query with pysql: 80 | ```bash 81 | docker exec -it postgres-sofi \ 82 | psql -U postgres -d gastitis -s public \ 83 | -c "SELECT * FROM expenses_expense ORDER BY id DESC LIMIT 100" 84 | ``` 85 | 86 | ## Check out existing users (or roles) 87 | ```bash 88 | docker exec -it postgres-sofi psql -U postgres -c "SELECT rolname FROM pg_roles" 89 | ``` 90 | 91 | ## Create a user 92 | ```bash 93 | docker exec -it postgres-sofi psql -U postgres -d gastitis -c "CREATE ROLE your_user WITH LOGIN PASSWORD 'your_password'" 94 | docker exec -it postgres-sofi psql -U postgres -d gastitis -c "GRANT ALL ON DATABASE gastitis TO your_user" 95 | docker exec -it postgres-sofi psql -U postgres -d gastitis -c "GRANT ALL ON ALL SCHEMA public TO your_user" 96 | ``` 97 | 98 | ## Change the ownership of a table 99 | ```bash 100 | docker exec -it postgres-sofi psql -U postgres -d gastitis -s public -c "ALTER TABLE bot_telegramgroup OWNER TO your_user" 101 | ``` 102 | -------------------------------------------------------------------------------- /services/google_sheets.py: -------------------------------------------------------------------------------- 1 | """ 2 | Integration with Google Sheets 3 | """ 4 | import asyncio 5 | from functools import wraps 6 | 7 | import gspread 8 | 9 | from gastitis.exceptions import GoogleAPIConnectionError 10 | 11 | CREDENTIALS_FILE = "gastitis/google_credentials.json" 12 | 13 | 14 | def asyncify(func): 15 | """ 16 | Decorator to run blocking functions asynchronously. 17 | """ 18 | @wraps(func) 19 | async def wrapper(*args, **kwargs): 20 | loop = asyncio.get_event_loop() 21 | return await loop.run_in_executor(None, func, *args, **kwargs) 22 | return wrapper 23 | 24 | def handle_google_connection_error(func): 25 | """ 26 | Decorator to handle errors in the connection with Google API. 27 | 28 | Only use this decorator in functions that wrap google calls, without other logic. 29 | """ 30 | @wraps(func) 31 | def wrapper(*args, **kwargs): 32 | try: 33 | return func(*args, **kwargs) 34 | except Exception as error: 35 | raise GoogleAPIConnectionError() from error 36 | 37 | return wrapper 38 | 39 | 40 | class GoogleClient: 41 | @asyncify 42 | @handle_google_connection_error 43 | def _get_service_account(self): 44 | if not hasattr(self, "_service_account"): 45 | self._service_account = gspread.service_account(filename=CREDENTIALS_FILE) 46 | return self._service_account 47 | 48 | @asyncify 49 | @handle_google_connection_error 50 | def _get_sheet_from_google(self, service_account, sheet_url): 51 | return service_account.open_by_url(sheet_url) 52 | 53 | async def get_sheet(self, sheet_url): 54 | service_account = await self._get_service_account() 55 | return await self._get_sheet_from_google(service_account, sheet_url) 56 | 57 | @asyncify 58 | @handle_google_connection_error 59 | def create_empty_worksheet(self, sheet, worksheet_name): 60 | return sheet.add_worksheet(worksheet_name, rows=1, cols=1) 61 | 62 | @asyncify 63 | @handle_google_connection_error 64 | def save_data_in_worksheet(self, worksheet, data): 65 | worksheet.update(data, "A1") 66 | 67 | google_client = GoogleClient() 68 | 69 | class GoogleSheet: 70 | def __init__(self, sheet_url): 71 | self.sheet_url = sheet_url 72 | 73 | async def get_sheet(self): 74 | if not hasattr(self, "_sheet"): 75 | try: 76 | self._sheet = await google_client.get_sheet(self.sheet_url) 77 | except gspread.exceptions.SpreadsheetNotFound as exc: 78 | raise Exception("remember to share the sheet") from exc #TODO: improve this 79 | return self._sheet 80 | 81 | 82 | async def create_new_worksheet(self, worksheet_name): 83 | sheet = await self.get_sheet() 84 | existing_worksheets = [w.title for w in sheet.worksheets()] 85 | 86 | worksheet_final_name = worksheet_name 87 | name_suffix = 0 88 | 89 | while worksheet_final_name in existing_worksheets: 90 | name_suffix += 1 91 | worksheet_final_name = f"{worksheet_name}-{name_suffix}" 92 | 93 | worksheet = await google_client.create_empty_worksheet(sheet, worksheet_final_name) 94 | 95 | return worksheet, worksheet_final_name 96 | 97 | async def save_data(self, worksheet_name, data): 98 | worksheet, worksheet_name = await self.create_new_worksheet(worksheet_name) 99 | 100 | await google_client.save_data_in_worksheet(worksheet, data) 101 | 102 | return worksheet_name 103 | -------------------------------------------------------------------------------- /extra_features/vianda.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utilities to calc the cost of viandas (usualy pay once a week) 3 | """ 4 | 5 | class MissingViandasOrDeliveries(Exception): 6 | """ 7 | Param viandas or deliveries is missing. 8 | """ 9 | 10 | 11 | class InvalidViandasArguments(Exception): 12 | """ 13 | User arguments are invalid. 14 | """ 15 | 16 | 17 | class ViandaCostCalculator: 18 | """ 19 | Calc the vianda cost, considering by default a week of viandas (5 days) with 20 | 2 viandas per day. 21 | """ 22 | 23 | FOOD_COST = 5000 24 | DELIVERY_COST = 900 25 | COSTS_LAST_UPDATED = "2025/05/05" 26 | 27 | DEFAULT_DAYS = 5 28 | DEFAULT_VIANDAS_PER_DAY = 2 29 | 30 | 31 | def __init__(self, days=DEFAULT_DAYS, viandas=None, deliveries=None): 32 | if not viandas and not deliveries: 33 | viandas = self.DEFAULT_VIANDAS_PER_DAY * days 34 | deliveries = days 35 | elif (viandas and not deliveries) or (deliveries and not viandas): 36 | raise MissingViandasOrDeliveries( 37 | "viandas and deliveries should both have a value or neither" 38 | ) 39 | 40 | self.viandas = viandas 41 | self.deliveries = deliveries 42 | 43 | def calc_cost(self): 44 | food_cost = self.viandas * self.FOOD_COST 45 | delivery_cost = self.deliveries * self.DELIVERY_COST 46 | 47 | cost = food_cost + delivery_cost 48 | 49 | return cost 50 | 51 | 52 | 53 | class ViandaMessage: 54 | """ 55 | Calc viandas cost and return a message for users. 56 | """ 57 | 58 | def __init__(self, *args): 59 | self.args = args 60 | 61 | def get_viandas_calculator(self): 62 | """ 63 | Based on user args, return the params needed to initialize ViandaCostCalculator. 64 | """ 65 | match len(self.args): 66 | # case no arguments: use default values 67 | case 0: 68 | return ViandaCostCalculator() 69 | 70 | # case one argument: interpret as days 71 | case 1: 72 | try: 73 | days = int(self.args[0]) 74 | except ValueError as exc: 75 | raise InvalidViandasArguments() from exc 76 | 77 | return ViandaCostCalculator(days=days) 78 | 79 | # case two arguments: interpret as viandas and deliveies 80 | case 2: 81 | viandas, deliveries = self.args 82 | try: 83 | viandas = int(viandas) 84 | deliveries = int(deliveries) 85 | 86 | except ValueError as exc: 87 | raise InvalidViandasArguments() from exc 88 | 89 | return ViandaCostCalculator(viandas=viandas, deliveries=deliveries) 90 | 91 | # more than 2 args is an error 92 | raise InvalidViandasArguments() 93 | 94 | 95 | def message(self): 96 | try: 97 | calc = self.get_viandas_calculator() 98 | except InvalidViandasArguments: 99 | message = [ 100 | f"Los parámetros {self.args} no son válidos.", 101 | "", 102 | "- Si no indicas parámetros se tomarán los valores por default.", 103 | "- Si indicas un parámetro se interpreta como cantidad de días", 104 | "- Si indicas dos parámetros se interpreta como cantidad de viandas y deliveries", 105 | ] 106 | 107 | return "\n".join(message) 108 | 109 | 110 | cost = calc.calc_cost() 111 | message = [ 112 | f"TOTAL ${cost}", 113 | "", 114 | "Para el cálculo se consideró:", 115 | f"- {calc.viandas} viandas (${calc.FOOD_COST} c/u)", 116 | f"- {calc.deliveries} envíos (${calc.DELIVERY_COST} c/u)", 117 | "", 118 | f"Última actualización de costos: {calc.COSTS_LAST_UPDATED}", 119 | ] 120 | 121 | message = "\n".join(message) 122 | 123 | return message 124 | -------------------------------------------------------------------------------- /gastitis/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for gastitis project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.1.7. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.1/ref/settings/ 11 | """ 12 | 13 | import os 14 | try: 15 | from gastitis.secret_settings import ( 16 | TELEGRAM_BOT_TOKEN, 17 | DJANGO_SECRET_KEY, 18 | DATABASE_SETTINGS, 19 | GOOGLE_SHEET_URL, 20 | BETA_USERS, 21 | ) 22 | 23 | except ModuleNotFoundError as e: 24 | raise Exception("Missing secret_settings module") from e 25 | 26 | except ImportError as e: 27 | raise Exception("some of the secret settings are missing") from e 28 | 29 | 30 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 31 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 32 | 33 | 34 | # Quick-start development settings - unsuitable for production 35 | # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ 36 | 37 | # SECURITY WARNING: keep the secret key used in production secret! 38 | SECRET_KEY = DJANGO_SECRET_KEY 39 | 40 | # SECURITY WARNING: don't run with debug turned on in production! 41 | DEBUG = True 42 | 43 | ALLOWED_HOSTS = [] 44 | 45 | 46 | # Application definition 47 | 48 | INSTALLED_APPS = [ 49 | 'django.contrib.admin', 50 | 'django.contrib.auth', 51 | 'django.contrib.contenttypes', 52 | 'django.contrib.sessions', 53 | 'django.contrib.messages', 54 | 'django.contrib.staticfiles', 55 | 56 | 'expenses', 57 | 'bot', 58 | ] 59 | 60 | MIDDLEWARE = [ 61 | 'django.middleware.security.SecurityMiddleware', 62 | 'django.contrib.sessions.middleware.SessionMiddleware', 63 | 'django.middleware.common.CommonMiddleware', 64 | 'django.middleware.csrf.CsrfViewMiddleware', 65 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 66 | 'django.contrib.messages.middleware.MessageMiddleware', 67 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 68 | ] 69 | 70 | ROOT_URLCONF = 'gastitis.urls' 71 | 72 | TEMPLATES = [ 73 | { 74 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 75 | 'DIRS': [], 76 | 'APP_DIRS': True, 77 | 'OPTIONS': { 78 | 'context_processors': [ 79 | 'django.template.context_processors.debug', 80 | 'django.template.context_processors.request', 81 | 'django.contrib.auth.context_processors.auth', 82 | 'django.contrib.messages.context_processors.messages', 83 | ], 84 | }, 85 | }, 86 | ] 87 | 88 | WSGI_APPLICATION = 'gastitis.wsgi.application' 89 | 90 | 91 | # Database 92 | # https://docs.djangoproject.com/en/2.1/ref/settings/#databases 93 | 94 | if DATABASE_SETTINGS: 95 | DATABASES = {'default': DATABASE_SETTINGS} 96 | else: 97 | DATABASES = { 98 | 'default': { 99 | 'ENGINE': 'django.db.backends.sqlite3', 100 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 101 | } 102 | } 103 | DATE_INPUT_FORMATS = ['%d-%m-%Y', '%Y-%m-%d', '%d/%m/%Y', '%Y/%m/%d'] 104 | 105 | 106 | # Password validation 107 | # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators 108 | 109 | AUTH_PASSWORD_VALIDATORS = [ 110 | { 111 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 112 | }, 113 | { 114 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 115 | }, 116 | { 117 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 118 | }, 119 | { 120 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 121 | }, 122 | ] 123 | 124 | 125 | # Internationalization 126 | # https://docs.djangoproject.com/en/2.1/topics/i18n/ 127 | 128 | LANGUAGE_CODE = 'en-us' 129 | 130 | TIME_ZONE = 'UTC' 131 | 132 | USE_I18N = True 133 | 134 | USE_L10N = True 135 | 136 | USE_TZ = True 137 | 138 | 139 | # Static files (CSS, JavaScript, Images) 140 | # https://docs.djangoproject.com/en/2.1/howto/static-files/ 141 | 142 | STATIC_URL = '/static/' 143 | 144 | SITE_DOMAIN = 'http://127.0.0.1:8000' 145 | 146 | # Telegram Settings 147 | BOT_TOKEN = TELEGRAM_BOT_TOKEN 148 | 149 | # Default auto field for django id 150 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 151 | 152 | print(f"Connecting to database {DATABASES['default']['NAME']}" ) 153 | 154 | -------------------------------------------------------------------------------- /use_cases/export.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | from django.db import connection 5 | from django.db.models import Func, F, ExpressionWrapper, FloatField, CharField 6 | from django.db.models.expressions import RawSQL 7 | from django.db.models.functions import Cast 8 | 9 | from expenses.models import Expense 10 | from gastitis.exceptions import NoExpensesInChat, UserNotAuthorized, GoogleAPIConnectionError 11 | from services.google_sheets import GoogleSheet 12 | 13 | def only_beta_users(username): 14 | """ 15 | Only allow beta users to perform this action. 16 | """ 17 | if username not in settings.BETA_USERS: 18 | raise UserNotAuthorized() 19 | 20 | 21 | class ExportExpenses: 22 | 23 | def __init__(self, user, group, extra_name=None, expense_filters=None): 24 | self.user = user 25 | self.group = group 26 | self.expense_filters = expense_filters if expense_filters is not None else {} 27 | self.extra_name = extra_name 28 | 29 | async def get_expenses(self): 30 | """ 31 | Export expenses to Google Sheet 32 | """ 33 | group_expenses_qs = Expense.objects.filter(group=self.group, **self.expense_filters) 34 | group_expenses_qs = group_expenses_qs.select_related("user").prefetch_related("tags") 35 | group_expenses_qs = self._fix_qs_format_to_serialization(group_expenses_qs) 36 | 37 | if not await group_expenses_qs.aexists(): 38 | raise NoExpensesInChat() 39 | 40 | return group_expenses_qs 41 | 42 | def _fix_qs_format_to_serialization(self, group_expenses_qs): 43 | db_backend = connection.vendor 44 | 45 | if db_backend == "postgresql": 46 | group_expenses_qs = group_expenses_qs.annotate( 47 | formatted_date=Func( 48 | F("date"), 49 | function="TO_CHAR", 50 | template="TO_CHAR(%(expressions)s, 'YYYY/MM/DD')", 51 | 52 | ) 53 | ) 54 | else: 55 | # Assume SQLite db, TO_CHAR doesn't work 56 | group_expenses_qs = group_expenses_qs.annotate( 57 | formatted_date=Cast('date', CharField()) 58 | ) 59 | 60 | group_expenses_qs = group_expenses_qs.annotate( 61 | amount_as_float=ExpressionWrapper( 62 | F("amount"), 63 | output_field=FloatField() 64 | ) 65 | ) 66 | 67 | group_expenses_qs = group_expenses_qs.values_list( 68 | "formatted_date", 69 | "user__username", 70 | "description", 71 | "amount_as_float", 72 | ) 73 | 74 | return group_expenses_qs 75 | 76 | 77 | async def get_expenses_table(self): 78 | table = [["fecha", "usuario", "gasto", "monto"]] 79 | expenses = await self.get_expenses() 80 | 81 | async for expense in expenses: 82 | table.append(expense) 83 | 84 | return table 85 | 86 | async def export_to_google_sheet(self, table): 87 | url, name = self.get_sheet_url_and_name() 88 | 89 | sheet = GoogleSheet(url) 90 | worksheet_name = await sheet.save_data(name, table) 91 | 92 | return url, worksheet_name 93 | 94 | def get_sheet_url_and_name(self): 95 | # TODO: save urls in db by group 96 | url = settings.GOOGLE_SHEET_URL 97 | 98 | name = self.group.name 99 | if self.extra_name: 100 | name += f"@{self.extra_name}" 101 | 102 | return url, name 103 | 104 | async def run(self): 105 | """ 106 | Get and export the expenses and return the message to send to the user 107 | """ 108 | 109 | try: 110 | only_beta_users(self.user.username) 111 | except UserNotAuthorized: 112 | return "Este comando está en beta. Solo usuarios autorizados pueden ejecutarlo." 113 | 114 | expenses = await self.get_expenses_table() 115 | 116 | try: 117 | sheet_url, worksheet_name = await self.export_to_google_sheet(expenses) 118 | except GoogleAPIConnectionError: 119 | logging.exception("Google API error - Check your credentials") 120 | return "Hubo un problema al intentar exportar (Error de conexión con API Google)" 121 | except Exception: 122 | logging.exception("Unhandled error") 123 | return "Hubo un problema al intentar exportar." 124 | 125 | text = "Exportado con éxito!\n\n" 126 | text += f"- Link de la hoja de cálculo: {sheet_url} \n\n" 127 | text += f"- Nombre de la nueva pestaña: *\"{worksheet_name}\"*." 128 | 129 | return text 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gastitis 2 | [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) 3 | ![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg) 4 | 5 | **Gastitis** is a Telegram bot designed to help you track your expenses easily and efficiently. 6 | 7 | --- 8 | 9 | ## Setting Up a Development Environment 10 | 11 | 12 | Follow these instructions to get a copy of the project up and running on your local machine. 13 | 14 | ### Creating a Telegram Bot 15 | 16 | To test or deploy this project, you need to create your own bots via **BotFather**. 17 | Refer to the [Telegram Bot documentation](https://core.telegram.org/bots#6-botfather) for detailed steps. 18 | 19 | It's recommended to create two bots: 20 | - **Testing Bot**: For use in your [development environment](#setting-up-a-dev-environment). 21 | - **Production Bot**: For deployment in your live environment. 22 | 23 | ### Creating a settings file 24 | 25 | Prepare a configuration file at `gastitis/secret_settings.py` with the following content: 26 | 27 | ```python 28 | TELEGRAM_BOT_TOKEN = '' # Place your bot token here. 29 | 30 | DJANGO_SECRET_KEY = '' # Generate a Django secret key (any random string) (see https://docs.djangoproject.com/en/4.1/ref/settings/#secret-key). 31 | 32 | SQLITE_DATABASE_SETTINGS = None # Use 'None' to run Gastitis with SQLite. 33 | 34 | POSTGRESQL_DATABASE_SETTINGS = { # Use these settings for a specific database (e.g., PostgreSQL). 35 | 'ENGINE': 'django.db.backends.postgresql', 36 | 'NAME': '', # Database name. 37 | 'USER': '', # Database username. 38 | 'PASSWORD': '', # Database password. 39 | 'HOST': '127.0.0.1', 40 | 'PORT': '5432', 41 | } 42 | 43 | DATABASE_SETTINGS = SQLITE_DATABASE_SETTINGS # Choose SQLITE_DATABASE_SETTINGS or POSTGRESQL_DATABASE_SETTINGS 44 | 45 | BETA_USERS = ['your_telegram_username'] # List of Telegram usernames allowed to access beta features and commands 46 | 47 | GOOGLE_SHEET_URL = '' # Place URL to Google Sheet for use in export command 48 | ``` 49 | 50 | > Note: For setting up a specific database like PostgreSQL, refer to the [Database Setup Guide](docs/database_setup.md). 51 | 52 | ### Setting Up Google Credentials for the /export Command 53 | The `/export` command requires Google credentials. Follow these steps to set them up: 54 | 55 | 1. Create a Google service account following the [gspread documentation](https://docs.gspread.org/en/v6.1.3/oauth2.html#service-account). 56 | 2. Download the credentials JSON file provided by Google. 57 | 3. Save the JSON file to the following location: gastitis/google_credentials.json. 58 | 59 | 60 | ### Installation Steps 61 | 62 | 1. Create a virtual environment and activate it: 63 | ```bash 64 | python3 -m venv env 65 | source env/bin/activate 66 | ``` 67 | 68 | 2. Install the required dependencies: 69 | ```bash 70 | pip install -r requirements.txt 71 | ``` 72 | 73 | 3. Apply database migrations: 74 | ```bash 75 | python manage.py migrate 76 | ``` 77 | 78 | 4. Start your bot: 79 | ```bash 80 | python manage.py startbot 81 | ``` 82 | You can now interact with your bot in Telegram! Use /help to see the available commands. 83 | 84 | ### Accessing the Admin Panel 85 | 86 | If you want to view the expenses you've added or any other data stored by your bot, you need to create 87 | a superuser for the Django admin panel: 88 | 89 | 1. Create a superuser: 90 | ```bash 91 | python manage.py createsuperuser 92 | ``` 93 | 94 | 2. Run the development server: 95 | ```bash 96 | python manage.py runserver 97 | ``` 98 | 99 | 3. Access the admin panel: Open your browser and go to http://localhost:8000/admin. Log in with the superuser 100 | credentials you created in the previous step. 101 | 102 | You can now explore the data your bot has generated! 103 | 104 | 105 | ### Extra Optional Steps: Configure Your Telegram Bot 106 | 107 | Enhance your Telegram bot's functionality by customizing its settings. Follow the instructions in the [Bot Manual Settings](docs/bot_manual_settings.md) for detailed guidance. 108 | 109 | ## Keep gastitis running in a server 110 | 111 | Follow the steps in ["keep_bot_running_in_server"](docs/keep_bot_running_in_server.md) to keep gastitis always running in your pc or server. 112 | 113 | To deploy new changes follow the steps in the ["Deploying New Changes" inside keep_bot_running_in_server](docs/keep_bot_running_in_server.md#deploying-new-changes) section. 114 | 115 | ## Contributing 116 | 117 | - After making any changes to the code, please run the [functional tests](docs/functional_testing.md) to ensure everything is working as expected. 118 | - If you are adding a new command, follow the instructions in the [Create a New Command](docs/create_new_command.md) section. 119 | 120 | ## Authors 121 | 122 | * Sofía Denner 123 | 124 | ## License 125 | 126 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 127 | -------------------------------------------------------------------------------- /bot/handlers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Telegram bot logic. 3 | """ 4 | import logging 5 | 6 | from telegram.ext import CommandHandler, MessageHandler, filters 7 | from telegram.constants import ParseMode 8 | 9 | from bot.help_texts import HELP_TEXT 10 | from bot.utils import ( 11 | get_month_expenses, 12 | get_month_and_year, 13 | is_group, 14 | get_month_filters, 15 | new_expense, 16 | new_payment, 17 | show_expenses, 18 | user_and_group, 19 | ) 20 | from expenses.models import Expense 21 | from extra_features.asado import how_much_asado_message 22 | from extra_features.vianda import ViandaMessage 23 | from use_cases.export import ExportExpenses 24 | 25 | 26 | logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 27 | level=logging.INFO) 28 | 29 | 30 | @user_and_group 31 | async def start(update, context, user, group): 32 | logging.info('[ /start ]: %s', update) 33 | text = "Hola {}!\n\n".format(user) 34 | text += "Este es un proyecto de juguete. Es para uso personal, y todavía se encuentra " \ 35 | "desarrollo. No se ofrecen garantías de seguridad ni de privacidad. Usalo bajo tu " \ 36 | "propio riesgo.\n\n" 37 | text += "Si no sabés qué hacer a continuación, /help\n\n" 38 | text += "This is a toy project, it's for personal use and it is still under " \ 39 | "development. There aren't any warranties of security or privacy. Use it under " \ 40 | "your own risk.\n\n" 41 | text += "If you don't know how to use this bot, just ask for /help\n\n" 42 | await context.bot.send_message(chat_id=update.message.chat_id, text=text) 43 | 44 | 45 | async def show_help(update, context): 46 | await context.bot.send_message( 47 | chat_id=update.message.chat_id, 48 | text=HELP_TEXT, 49 | parse_mode=ParseMode.MARKDOWN 50 | ) 51 | 52 | 53 | @user_and_group 54 | async def load_expense(update, context, user, group): 55 | text = await new_expense(context.args, user, group) 56 | await context.bot.send_message(chat_id=update.message.chat_id, text=text) 57 | 58 | 59 | @user_and_group 60 | async def load_payment(update, context, user, group): 61 | text = await new_payment(context.args, update, user, group) 62 | await context.bot.send_message(chat_id=update.message.chat_id, text=text) 63 | 64 | 65 | @user_and_group 66 | async def total_expenses(update, context, user, group): 67 | text = await show_expenses(group) 68 | await context.bot.send_message( 69 | chat_id=update.message.chat_id, text=text, parse_mode=ParseMode.MARKDOWN 70 | ) 71 | 72 | 73 | @user_and_group 74 | async def month_expenses(update, context, user, group): 75 | month, year = get_month_and_year(context.args) 76 | text = await get_month_expenses(group, year, month) 77 | await context.bot.send_message( 78 | chat_id=update.message.chat_id, text=text, parse_mode=ParseMode.MARKDOWN 79 | ) 80 | 81 | async def calc_asado(update, context): 82 | try: 83 | people = int(context.args[0]) 84 | text = how_much_asado_message(people) 85 | except: 86 | text = "Hubo un problema. Recordá pasar la cantidad de personas como parámetro." 87 | 88 | await context.bot.send_message(chat_id=update.message.chat_id, text=text) 89 | 90 | 91 | @user_and_group 92 | async def export(update, context, user, group): 93 | exporter = ExportExpenses(user, group) 94 | 95 | text = await exporter.run() 96 | await context.bot.send_message( 97 | chat_id=update.message.chat_id, text=text, parse_mode=ParseMode.MARKDOWN 98 | ) 99 | 100 | 101 | @user_and_group 102 | async def export_month(update, context, user, group): 103 | month, year = get_month_and_year(context.args) 104 | expense_filters = get_month_filters(year, month) 105 | extra_name = f"{year}-{month}" 106 | exporter = ExportExpenses(user, group, extra_name, expense_filters) 107 | text = await exporter.run() 108 | await context.bot.send_message( 109 | chat_id=update.message.chat_id, text=text, parse_mode=ParseMode.MARKDOWN 110 | ) 111 | 112 | 113 | async def vianda(update, context): 114 | vianda_message = ViandaMessage(*context.args) 115 | 116 | text = vianda_message.message() 117 | await context.bot.send_message( 118 | chat_id=update.message.chat_id, text=text, parse_mode=ParseMode.MARKDOWN 119 | ) 120 | 121 | 122 | async def unknown(update, context): 123 | text = "Perdón, ese comando no lo entiendo. Si no sabés que hacer, /help." 124 | await context.bot.send_message(chat_id=update.message.chat_id, text=text) 125 | 126 | 127 | HANDLERS = [ 128 | CommandHandler('start', start), 129 | CommandHandler('help', show_help), 130 | CommandHandler('gasto', load_expense), 131 | CommandHandler('g', load_expense), 132 | CommandHandler('pago', load_payment), 133 | CommandHandler('p', load_payment), 134 | CommandHandler('total', total_expenses), 135 | CommandHandler('mes', month_expenses), 136 | CommandHandler('month', month_expenses), 137 | CommandHandler('m', month_expenses), 138 | CommandHandler('asado', calc_asado), 139 | CommandHandler('a', calc_asado), 140 | CommandHandler('exportar', export), 141 | CommandHandler('exportar_mes', export_month), 142 | CommandHandler('vianda', vianda), 143 | MessageHandler(filters.COMMAND, unknown), 144 | ] 145 | -------------------------------------------------------------------------------- /bot/utils.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from decimal import Decimal, InvalidOperation 3 | 4 | from django.contrib.auth.models import User 5 | from django.db.models import Sum 6 | 7 | from bot.exceptions import ParameterError, DateFormatterError 8 | from bot.models import TelegramUser, TelegramGroup 9 | from expenses.models import Expense, Tag, ExchangeRate, Payment, CURRENCY 10 | from gastitis.settings import DATE_INPUT_FORMATS 11 | 12 | 13 | def user_and_group(func): 14 | """ 15 | Add user and group to handler params. 16 | """ 17 | async def wrapper(update, context): 18 | user_data = update.message.from_user 19 | chat_id = user_data.id 20 | first_name = getattr(user_data, 'first_name', chat_id) 21 | last_name = getattr(user_data, 'last_name') or '-' 22 | telegram_username = getattr(user_data, 'username', '') 23 | username = telegram_username or first_name 24 | user, _ = await User.objects.aget_or_create(telegram__chat_id=chat_id, defaults={ 25 | 'username': username, 26 | 'first_name': first_name, 27 | 'last_name': last_name, 28 | }) 29 | 30 | await TelegramUser.objects.aupdate_or_create( 31 | user=user, chat_id=chat_id, defaults={ 32 | 'username': telegram_username 33 | }) 34 | group_data = update.message.chat 35 | group_id = group_data.id 36 | group_name = group_data.title or username + '__private' 37 | 38 | group, _ = await TelegramGroup.objects.aget_or_create(chat_id=group_id, defaults={ 39 | 'name': group_name, 40 | }) 41 | await group.users.aadd(user) 42 | 43 | 44 | await func(update, context, user, group) 45 | 46 | return wrapper 47 | 48 | 49 | async def new_expense(params, user, group): 50 | """ 51 | Check if params are valid and create a new expense. 52 | 53 | Returns a text to send to the user. 54 | """ 55 | try: 56 | data = await decode_expense_params(params, group) 57 | except ParameterError as e: 58 | return str(e) 59 | 60 | response_text = '' 61 | amount = data['amount'] 62 | description = data['description'] 63 | date = data['dd'] 64 | tags = data['tt'] 65 | user = data['uu'] or user 66 | expense = Expense(user=user, group=group, description=description, amount=amount, date=date) 67 | if data['exchange_rate']: 68 | exchange_rate = data['exchange_rate'] 69 | expense.original_currency = exchange_rate.currency 70 | expense.original_amount = data['original_amount'] 71 | response_text += 'Tu gasto se convirtió de {} a $ usando un tipo de cambio = ${} \ 72 | (cargado el {}).\n\n'.format( 73 | CURRENCY[exchange_rate.currency], 74 | exchange_rate.rate, 75 | exchange_rate.date 76 | ) 77 | await expense.asave() 78 | if tags: 79 | for tag in tags: 80 | await expense.tags.aadd(tag) 81 | 82 | if data['uu'] is None: 83 | response_text += 'Se guardó tu gasto {}'.format(expense) 84 | else: 85 | response_text += 'Se guardó el gasto que hizo {} para {}'.format(expense.user, expense) 86 | return response_text 87 | 88 | 89 | def parse_date(date_str, date_formats): 90 | for format in date_formats: 91 | try: 92 | return dt.datetime.strptime(date_str, format).date() 93 | except ValueError: 94 | continue 95 | raise DateFormatterError(f"Formato de fecha no válido: {date_str}") 96 | 97 | 98 | async def decode_expense_params(params, group): 99 | """ 100 | Process command params in expense's attributes, and return a dict with the following data: 101 | amount = expense amount. 102 | dd = date or None 103 | tt = Tag instance or None 104 | uu = User or None 105 | description = string, expense description 106 | """ 107 | # define special arguments and help texts for them 108 | special_arguments = { 109 | 'dd': 'Colocar la fecha en la que se generó el gasto después del argumento "dd"', 110 | 'tt': 'Luego de "tt" colocar el nombre de la/las etiqueta/s para el gasto que estás '\ 111 | 'cargando. Podés ingresar más de una etiqueta separando los nombres por comas (sin '\ 112 | 'espacio).', 113 | 'uu': 'A quién le estás cargando el gasto. Si no lo pasás, se te carga a vos.', 114 | } 115 | 116 | data = {} 117 | 118 | if not params: 119 | text = 'Necesito que me digas cuanto pagaste y una descripción del gasto.' 120 | raise ParameterError(text) 121 | 122 | # handle amount 123 | amount_received, *params = params 124 | 125 | amount, exchange_rate, original_amount = await get_amount_and_currency(amount_received) 126 | data['amount'] = amount 127 | data['exchange_rate'] = exchange_rate 128 | data['original_amount'] = original_amount 129 | 130 | #look for special arguments 131 | for argument, text in special_arguments.items(): 132 | try: 133 | argument_position = params.index(argument) 134 | params.pop(argument_position) 135 | data[argument] = params.pop(argument_position) 136 | 137 | except ValueError: 138 | data[argument] = None 139 | except IndexError: 140 | raise ParameterError(text) 141 | 142 | # handle description 143 | if not params: 144 | raise ParameterError('Necesito que agregues en el comando una descripción del gasto') 145 | 146 | data['description'] = ' '.join(params) 147 | 148 | # handle date 149 | if not data['dd']: 150 | data['dd'] = dt.date.today() 151 | else: 152 | try: 153 | data['dd'] = parse_date(data['dd'], DATE_INPUT_FORMATS) 154 | except DateFormatterError: 155 | example_date = dt.date.today() 156 | formated_dates = [dt.datetime.strftime(example_date, format) for format in DATE_INPUT_FORMATS] 157 | date_bullets = '\n - '.join(formated_dates) 158 | text = f'El formato de fecha no es correcto. ' \ 159 | f'Por ejemplo a la fecha de hoy la podés escribir en cualquiera ' \ 160 | f'de los siguientes formatos: \n - {date_bullets}' 161 | raise ParameterError(text) 162 | 163 | # handle tags 164 | if data['tt']: 165 | tags_list = [] 166 | for t in data['tt'].split(','): 167 | tag_instnce, _ = await Tag.objects.aget_or_create(name=data['tt'], group=group) 168 | tags_list.append(tag_instnce) 169 | data['tt'] = tags_list 170 | 171 | # handle user 172 | if data['uu']: 173 | # Remove the '@' from the username input: 174 | if data['uu'][0] == '@': 175 | data['uu'] = data['uu'][1:] 176 | # Only already registered users: 177 | try: 178 | data['uu'] = await group.users.aget(username=data['uu']) 179 | except User.DoesNotExist: 180 | text = 'Luego del parámetro "uu" necesito que ingreses un nombre de usuario válido.\n\n' 181 | text += 'El usuario debe previamente haber enviado algún comando en el grupo para que gastitis lo registre. ' 182 | text += 'Ej: /total' 183 | raise ParameterError(text) 184 | 185 | return data 186 | 187 | 188 | async def get_amount_and_currency(raw_amount): 189 | """ 190 | Given a string it returns an amount (in the default currency), the original amount and a 191 | ExchangeRate instance. If the string doesn't have a currency specified, it assumes the 192 | default currency and returns None as ExchangeRate. 193 | 194 | Params: 195 | - raw_amount = string of an amount (it may have a currency) 196 | Returns: 197 | - amount = Decimal number that represent the amount in the default currency. 198 | - exchange_rate = an exchange rate instance or None if the amount is in the default 199 | currency. 200 | - original_amount = the raw amount received, converted in Decimal. 201 | """ 202 | for key, value in CURRENCY.items(): 203 | if raw_amount.startswith((key, value)) or raw_amount.endswith((key, value)): 204 | # TODO: get current exchanger rate from api. 205 | exchange_rate = await ExchangeRate.objects.filter(currency=key).alast() 206 | break 207 | else: 208 | key, value = ['', ''] 209 | exchange_rate = None 210 | amount_without_currency = raw_amount.replace(value, '').replace(key, '') 211 | 212 | try: 213 | original_amount = amount_without_currency.replace(',', '.') 214 | original_amount = Decimal(original_amount) 215 | except InvalidOperation: 216 | text = 'El primer valor que me pasas después del comando tiene que ser el valor de lo '\ 217 | 'que pagaste. \n\n También podés especificar un tipo de cambio con el codigo y '\ 218 | ' monto, por ejemplo 40u para 40 dolares (o usd40). \n Los códigos posibles son:' 219 | for k, v in CURRENCY.items(): 220 | text += '\n - {} ({})'.format(k, v) 221 | text += '\n - {}'.format(v) 222 | 223 | text += '\n\n El valor "{}" no es un número válido.'.format(amount_without_currency) 224 | raise ParameterError(text) 225 | if exchange_rate: 226 | amount = original_amount * exchange_rate.rate 227 | else: 228 | amount = original_amount 229 | 230 | return amount, exchange_rate, original_amount 231 | 232 | 233 | async def new_payment(params, update, user, group): 234 | """ 235 | Save a new Payment instance. 236 | params can have two values (amount and user to pay) or three values (amount, user to pay and 237 | date to save the payment. 238 | """ 239 | if await group.users.acount() == 1: 240 | text = "Solo se pueden cargar pagos entre usuarios dentro de un grupo. Este chat tiene "\ 241 | "un único miembro, por lo que no se pueden realizar pagos." 242 | return text 243 | 244 | DATE_FORMAT = '%d/%m/%y' 245 | if len(params) == 3: 246 | date = params.pop(-1) 247 | else: 248 | date = dt.date.today().strftime(DATE_FORMAT) 249 | try: 250 | amount, to_user = params 251 | amount = float(amount) 252 | if to_user.startswith("@"): 253 | to_user = to_user[1:] 254 | to_user = await User.objects.exclude(pk=user.pk).aget(username=to_user, telegram_groups=group) 255 | date = dt.datetime.strptime(date, DATE_FORMAT) 256 | except ValueError: 257 | text = "El primer argumento debe ser el monto a pagar, y el segundo argumento el "\ 258 | "username del usuario al que le estás pagando. \n\n"\ 259 | "Opcionalmente puede contener un tercer argumento con la fecha en la que se "\ 260 | "desea computar el gasto, con el formato dd/mm/yy." 261 | return text 262 | except User.DoesNotExist: 263 | text = "El usuario espcificado ({}) no existe dentro de este grupo. \n".format(to_user) 264 | text += "Los posibles usuarios a los que les podes cargar un pago son: \n" 265 | async for member in group.users.exclude(pk=user.pk): 266 | text += "- {}\n".format(member.username) 267 | return text 268 | 269 | payment_details = { 270 | 'from_user': user, 271 | 'to_user': to_user, 272 | 'group': group, 273 | 'amount': amount, 274 | 'date': date, 275 | } 276 | payment = await Payment.objects.acreate(**payment_details) 277 | 278 | return "Se ha registrado su pago a {} por ${} en la fecha {}".format(to_user, amount, date) 279 | 280 | 281 | async def show_expenses(group, **expense_filters): 282 | """ 283 | Return a text with expenses processed and filtered according to the expense filters recived. 284 | """ 285 | group_expenses_qs = Expense.objects.filter(group=group, **expense_filters) 286 | if not await group_expenses_qs.aexists(): 287 | return "Todavía no hay gastos cargados en este grupo" 288 | total_expenses = await group_expenses_qs.aaggregate(Sum('amount')) 289 | total_expenses = total_expenses['amount__sum'] 290 | total_expenses = round(total_expenses, 2) 291 | user_expenses = {} 292 | quantity_expenses = await group_expenses_qs.acount() 293 | text = "*Total: ${} ({} gastos)*\n".format(total_expenses, quantity_expenses) 294 | if await group.users.acount() > 1: 295 | async for user in group.users.all(): 296 | expense_qs = group_expenses_qs.filter(user=user) 297 | expenses = await expense_qs.aaggregate(Sum('amount')) 298 | expenses = expenses['amount__sum'] or 0 299 | 300 | payments_done = user.payments_done.filter(group=group, **expense_filters) 301 | payments_done = await payments_done.aaggregate(Sum('amount')) 302 | payments_done = payments_done['amount__sum'] or 0 303 | 304 | payments_recived = user.payments_recived.filter(group=group, **expense_filters) 305 | payments_recived = await payments_recived.aaggregate(Sum('amount')) 306 | payments_recived = payments_recived['amount__sum'] or 0 307 | 308 | amount = expenses + payments_done - payments_recived 309 | amount = round(amount, 2) 310 | user_expenses[user.username] = amount 311 | 312 | for user, total in user_expenses.items(): 313 | text += "- {}: ${} ({}%)\n".format(user, total, round(total/total_expenses*100)) 314 | 315 | expenses_equal_parts = round(total_expenses / len(user_expenses), 2) 316 | text += f"\n\nPara estar a mano cada uno debería haber gastado ${expenses_equal_parts}:\n" 317 | for user, total in user_expenses.items(): 318 | if total < expenses_equal_parts: 319 | debt = round(expenses_equal_parts - total, 2) 320 | text += f"- {user} debe pagar ${debt}.\n" 321 | 322 | elif total > expenses_equal_parts: 323 | credit = round(total - expenses_equal_parts, 2) 324 | text += f"- {user} debe recibir ${credit}.\n" 325 | 326 | else: 327 | text += f" - {user} no debe ni le deben nada.\n" 328 | 329 | return text 330 | 331 | 332 | def get_month_filters(year, month): 333 | first_day_of_month = dt.date(year, month, 1) 334 | if month == 12: 335 | next_month = 1 336 | year += 1 337 | else: 338 | next_month = month + 1 339 | first_day_of_next_month = dt.date(year, next_month, 1) 340 | 341 | expense_filters = { 342 | 'date__gte': first_day_of_month, 343 | 'date__lt': first_day_of_next_month, 344 | } 345 | return expense_filters 346 | 347 | 348 | async def get_month_expenses(group, year, month): 349 | expense_filters = get_month_filters(year, month) 350 | text = "Gastos del mes {} del año {}\n\n".format(month, year) 351 | text += await show_expenses(group, **expense_filters) 352 | return text 353 | 354 | 355 | def get_month_and_year(params): 356 | today = dt.date.today() 357 | month = today.month 358 | year = today.year 359 | if not params: 360 | return month, year 361 | elif len(params) == 1: 362 | param_month = params[0] 363 | param_year = year 364 | else: 365 | param_month, param_year = params 366 | 367 | try: 368 | param_month = int(param_month) 369 | param_year = int(param_year) 370 | if param_month <= 12: 371 | month = param_month 372 | year = param_year 373 | if year < 100: 374 | year += 2000 375 | 376 | except: 377 | pass 378 | 379 | return month, year 380 | 381 | 382 | def is_group(update): 383 | # if the sender id is the same as the chat id, the message is recived from a private chat 384 | return update.message.from_user.id != update.message.chat_id 385 | --------------------------------------------------------------------------------