diff --git a/.coveragerc b/.coveragerc index a9df633..a271288 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,10 +1,12 @@ [run] branch = True -omit = *migrations*, - *urls*, - *test*, - *admin*, - ./manage.py, - ./flite/config/*, - ./flite/wsgi.py, - *__init__* +omit = +*/migrations/*, + */tests/*, + */admin.py, + *urls*, + flite/core/apps.py, + manage.py, + ./flite/config/*, + flite/wsgi.py, + */__init__.py \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5c3ca16..49f0b40 100644 --- a/.gitignore +++ b/.gitignore @@ -106,4 +106,6 @@ venv.bak/ # mypy .mypy_cache/ .vscode/ -.idea/ \ No newline at end of file +.idea/ +.DS_Store +data/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d2d130e..d18cac8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.6 +FROM python:3.8 ENV PYTHONUNBUFFERED 1 # Allows docker to cache installed dependencies between builds @@ -14,4 +14,4 @@ EXPOSE 8000 # Migrates the database, uploads staticfiles, and runs the production server CMD ./manage.py migrate && \ ./manage.py collectstatic --noinput && \ - newrelic-admin run-program gunicorn --bind 0.0.0.0:$PORT --access-logfile - flite.wsgi:application + newrelic-admin run-program gunicorn --bind 0.0.0.0:$PORT --access-logfile - flite.wsgi:application \ No newline at end of file diff --git a/README.md b/README.md index 311446d..2904570 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,125 @@ # Flite -#TODO: update readme for local development \ No newline at end of file +## Features + +- RESTful API endpoints for seamless integration with frontend applications +- User authentication and authorization using Django's built-in authentication system +- Asynchronous task processing with Celery and RabbitMQ +- Dockerized development environment for easy setup and deployment +- Comprehensive test suite for ensuring code quality and reliability +- Integration with [Mailpit](https://github.com/axllent/mailpit) for capturing outgoing emails during development +- Monitoring and administration of Celery tasks using Flower + +## Prerequisites + +Before getting started with Flite, ensure that you have the following prerequisites installed on your system: + +- Docker: [Install Docker](https://docs.docker.com/get-docker/) +- Docker Compose: [Install Docker Compose](https://docs.docker.com/compose/install/) + +## Getting Started + +To set up the Flite project on your local machine, follow these steps: + +1. Clone the repository: + ``` + git clone https://github.com/smyja/flite.git + ``` + +2. Navigate to the project directory: + ``` + cd flite + ``` + +3. Create a `.env` file in the project root and provide the necessary environment variables, example variables are in the `.env.example` file. + +4. Build and start the Docker containers: + ``` + docker-compose up --build + ``` + This command will build the required Docker images and start the containers defined in the `docker-compose.yml` file. + +5. Run database migrations: + Open another terminal and run the following commands + ``` + docker-compose exec django python manage.py makemigrations + ``` + and + + ``` + docker-compose exec django python manage.py migrate + ``` + + This command will apply the database migrations and set up the required tables. + +### Access the application: + - Django server: http://0.0.0.0:8000 + - Mailpit: http://0.0.0.0:8025 + - Flower: http://0.0.0.0:5555 + +## API Documentation + +The API documentation for Flite is generated using drf-yasg, a Swagger generation tool for Django REST Framework. To access the API documentation, follow these steps: + +1. Start the Django development server: + ``` + docker-compose up + ``` + +2. Open your web browser and navigate to: `http://0.0.0.0:8000/docs/` + + This will display the Redoc UI, where you can explore the available API endpoints, view request and response schemas, To interact with the API navigate to `http://0.0.0.0:8000/api/playground/` + To see all the endpoints, create a superuser account and login to the admin, then refresh the docs to see the rest of the endpoints that are protected. + +### Available endpoints +Sure! Here's a list of the available endpoints based on the provided code: + +1. Budget Category List: + - URL: `/budget_categories/` + - Methods: + - GET: Retrieve a list of budget categories for the authenticated user. + - POST: Create a new budget category for the authenticated user. + +2. Budget Category Detail: + - URL: `/budget_categories//` + - Methods: + - GET: Retrieve details of a specific budget category. + - PUT: Update a specific budget category. + - DELETE: Delete a specific budget category. + +3. Transaction List: + - URL: `/transactions/` + - Methods: + - GET: Retrieve a list of transactions for the authenticated user. + - POST: Create a new transaction for the authenticated user. + +4. Transaction Detail: + - URL: `/transactions//` + - Methods: + - GET: Retrieve details of a specific transaction. + - PUT: Update a specific transaction. + - DELETE: Delete a specific transaction. + +Note that these endpoints require authentication using token-based authentication. Users need to provide a valid token in the request headers to access these endpoints. For example +```bash +curl -X POST \ + -H "Authorization: Token your_token_here" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Food", + "description": "Budget for groceries and dining out", + "max_spend": 500.00 + }' \ + http://localhost:8000/budget_categories/ + ``` + +## Running Tests + +To run the test suite for the Flite project, use the following command in another terminal/tab: + +``` +docker-compose exec django python manage.py test +``` + +This command will execute the test cases defined in the Django application and provide the test results. + diff --git a/docker-compose.yml b/docker-compose.yml index 3b6f065..face7bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,11 +14,19 @@ services: POSTGRES_USER: postgres ports: - "5432:5432" - + rabbitmq: + image: "rabbitmq:3-management" + container_name: rabbitmq + environment: + - RABBITMQ_DEFAULT_USER=guest + - RABBITMQ_DEFAULT_PASS=guest + ports: + - "5672:5672" + - "15672:15672" django: restart: always environment: - - DJANGO_SECRET_KEY=local + - DJANGO_SECRET_KEY=Local image: django container_name: django build: ./ @@ -32,3 +40,38 @@ services: - "8000:8000" depends_on: - postgres + celery-worker: + build: . + command: celery -A flite worker -l info + environment: + - DJANGO_SETTINGS_MODULE=flite.config + - CELERY_BROKER_URL=amqp://guest:guest@rabbitmq:5672/ + volumes: + - ./:/code + depends_on: + - django + - rabbitmq + mailpit: + image: axllent/mailpit + container_name: mailpit + restart: unless-stopped + volumes: + - ./data:/data + ports: + - 8025:8025 + - 1025:1025 + environment: + MP_MAX_MESSAGES: 5000 + MP_DATABASE: /data/mailpit.db + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 + flower: + image: mher/flower + command: celery flower + environment: + - CELERY_BROKER_URL=amqp://guest:guest@rabbitmq:5672// + ports: + - 5555:5555 + depends_on: + - rabbitmq + restart: always diff --git a/env.example b/env.example index 309c8a5..7f5b6f2 100644 --- a/env.example +++ b/env.example @@ -1,7 +1,9 @@ -DJANGO_SECRET_KEY=thisgvbhgjahsjgbasfdafsadgh DATABASE_URL=postgres://postgres:postgres@postgres:5432/flite POSTGRES_HOST=postgres POSTGRES_PORT=5432 +POSTGRES_PASSWORD=postgres POSTGRES_DB=flite POSTGRES_USER=postgres -POSTGRES_PASSWORD=postgres \ No newline at end of file +DJANGO_SECRET_KEY=local +RABBITMQ_DEFAULT_USER=guest +RABBITMQ_DEFAULT_PASS=guest \ No newline at end of file diff --git a/flite/__init__.py b/flite/__init__.py index e69de29..1e3599b 100644 --- a/flite/__init__.py +++ b/flite/__init__.py @@ -0,0 +1,5 @@ +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +from .celery import app as celery_app + +__all__ = ('celery_app',) \ No newline at end of file diff --git a/flite/celery.py b/flite/celery.py new file mode 100644 index 0000000..445a1ae --- /dev/null +++ b/flite/celery.py @@ -0,0 +1,17 @@ +from __future__ import absolute_import +import os + +from celery import Celery + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "flite.config.local") +os.environ.setdefault('DJANGO_CONFIGURATION', 'Local') +import configurations +configurations.setup() + +app = Celery('flite') + +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Load task modules from all registered Django apps. +app.autodiscover_tasks() diff --git a/flite/config/common.py b/flite/config/common.py index ebe14cc..042a721 100755 --- a/flite/config/common.py +++ b/flite/config/common.py @@ -25,11 +25,12 @@ class Common(Configuration): # Third party apps 'rest_framework', # utilities for rest apis 'rest_framework.authtoken', # token authentication - 'django_filters', # for filtering rest endpoints - + 'django_filters', + "drf_yasg", + # Your apps 'flite.users', - 'flite.core', + 'flite.core.apps.CoreConfig', ) @@ -85,7 +86,7 @@ class Common(Configuration): 'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder', ) - + CELERY_BROKER_URL = 'amqp://guest:guest@rabbitmq:5672//' STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' # Media files MEDIA_ROOT = join(os.path.dirname(BASE_DIR), 'media') diff --git a/flite/config/local.py b/flite/config/local.py index b9563c6..4cbe48a 100755 --- a/flite/config/local.py +++ b/flite/config/local.py @@ -20,6 +20,6 @@ class Local(Common): ] # Mail - EMAIL_HOST = 'localhost' + EMAIL_HOST = 'mailpit' EMAIL_PORT = 1025 - EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' diff --git a/flite/core/admin.py b/flite/core/admin.py index c7a959e..89195bc 100644 --- a/flite/core/admin.py +++ b/flite/core/admin.py @@ -1,3 +1,5 @@ from django.contrib import admin +from .models import BudgetCategory,Transaction - +admin.site.register(BudgetCategory) +admin.site.register(Transaction) diff --git a/flite/core/apps.py b/flite/core/apps.py index 26f78a8..65ceb7f 100644 --- a/flite/core/apps.py +++ b/flite/core/apps.py @@ -1,5 +1,12 @@ from django.apps import AppConfig - +from django.db.models.signals import post_save class CoreConfig(AppConfig): - name = 'core' + default_auto_field = 'django.db.models.BigAutoField' + name = 'flite.core' + + def ready(self): + from .models import Transaction + from .tasks import check_budget_threshold_signal + + post_save.connect(check_budget_threshold_signal, sender=Transaction) \ No newline at end of file diff --git a/flite/core/migrations/0001_initial.py b/flite/core/migrations/0001_initial.py new file mode 100644 index 0000000..fb9f17d --- /dev/null +++ b/flite/core/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.16 on 2024-05-01 16:35 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BudgetCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('description', models.TextField()), + ('max_spend', models.DecimalField(decimal_places=2, max_digits=10)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budget_categories', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Transaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('description', models.TextField()), + ('date', models.DateTimeField(auto_now_add=True)), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.budgetcategory')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/flite/core/models.py b/flite/core/models.py index 6c02375..fa886df 100644 --- a/flite/core/models.py +++ b/flite/core/models.py @@ -1,8 +1,9 @@ -from django.db import models import uuid +from django.db import models from django.utils import timezone +from django.contrib.auth.models import User +from django.conf import settings -# Create your models here. class BaseModel(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) created = models.DateTimeField(default=timezone.now, editable=False) @@ -10,3 +11,23 @@ class BaseModel(models.Model): class Meta: abstract = True + +class BudgetCategory(models.Model): + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='budget_categories') + name = models.CharField(max_length=200) + description = models.TextField() + max_spend = models.DecimalField(max_digits=10, decimal_places=2) + + def __str__(self): + return self.name + +class Transaction(models.Model): + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE,related_name='transactions') + # other fields... + category = models.ForeignKey(BudgetCategory, on_delete=models.CASCADE) + amount = models.DecimalField(max_digits=10, decimal_places=2) + description = models.TextField() + date = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.category.name} - {self.amount}" \ No newline at end of file diff --git a/flite/core/serializers.py b/flite/core/serializers.py new file mode 100644 index 0000000..f090996 --- /dev/null +++ b/flite/core/serializers.py @@ -0,0 +1,16 @@ +from rest_framework import serializers +from .models import BudgetCategory, Transaction + +class BudgetCategorySerializer(serializers.ModelSerializer): + class Meta: + model = BudgetCategory + fields = ['id', 'name', 'description', 'max_spend'] + read_only_fields = ['owner'] + +class TransactionSerializer(serializers.ModelSerializer): + amount = serializers.DecimalField(max_digits=10, decimal_places=2) + + class Meta: + model = Transaction + fields = ['id', 'category', 'amount', 'description', 'date'] + read_only_fields = ['owner'] \ No newline at end of file diff --git a/flite/core/tasks.py b/flite/core/tasks.py new file mode 100644 index 0000000..85dc7f6 --- /dev/null +++ b/flite/core/tasks.py @@ -0,0 +1,34 @@ +from celery import shared_task +from django.core.mail import send_mail +from django.db.models import Sum +from decimal import Decimal + +@shared_task +def send_threshold_email(user_email, category_name, total_spending, budget, subject): + send_mail( + subject, + f'Your spending for {category_name} has reached {total_spending}. The predefined threshold is {budget}.', + 'flite@cowrywise.com', + [user_email], + fail_silently=False, + ) + +def check_budget_threshold(instance): + from .models import Transaction + + category = instance.category + total_spending = Transaction.objects.filter(category=category).aggregate(Sum('amount'))['amount__sum'] + + if total_spending is None: + return + + budget = Decimal(str(category.max_spend)) + user_email = instance.owner.email + + if total_spending >= budget * Decimal('0.5') and total_spending < budget: + send_threshold_email.delay(user_email, category.name, str(total_spending), str(budget), 'Budget threshold warning') + elif total_spending >= budget: + send_threshold_email.delay(user_email, category.name, str(total_spending), str(budget), 'Budget limit exceeded') + +def check_budget_threshold_signal(sender, instance, **kwargs): + check_budget_threshold(instance) \ No newline at end of file diff --git a/flite/core/test/__init__.py b/flite/core/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/flite/core/test/test_models.py b/flite/core/test/test_models.py new file mode 100644 index 0000000..d134f85 --- /dev/null +++ b/flite/core/test/test_models.py @@ -0,0 +1,27 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase +from flite.core.models import BudgetCategory, Transaction +from flite.users.models import User + +class TestBudgetCategoryModel(TestCase): + def test_str_representation(self): + category = BudgetCategory(name='Test Category', description='Test description', max_spend=100.00) + self.assertEqual(str(category), 'Test Category') + +class TestTransactionModel(TestCase): + def setUp(self): + self.user = User.objects.create_user('testuser', 'test@example.com', 'password') + self.category = BudgetCategory.objects.create(name='Test Category', description='Test description', max_spend=100.00, owner=self.user) + + def test_total_amount(self): + Transaction.objects.create(owner=self.user, category=self.category, amount=50.00, description='Test transaction 1') + Transaction.objects.create(owner=self.user, category=self.category, amount=20.00, description='Test transaction 2') + Transaction.objects.create(owner=self.user, category=self.category, amount=30.00, description='Test transaction 3') + + self.assertEqual(self.user.total_amount, 100.00) + + + def test_empty_description(self): + with self.assertRaises(ValidationError): + transaction = Transaction(owner=self.user, category=self.category, amount=10.00, description='') + transaction.full_clean() \ No newline at end of file diff --git a/flite/core/test/test_serializers.py b/flite/core/test/test_serializers.py new file mode 100644 index 0000000..05cc539 --- /dev/null +++ b/flite/core/test/test_serializers.py @@ -0,0 +1,62 @@ +from django.test import TestCase +from nose.tools import eq_ +from flite.core.serializers import BudgetCategorySerializer, TransactionSerializer +from flite.core.models import BudgetCategory, Transaction +from flite.users.models import User + +class TestBudgetCategorySerializer(TestCase): + def setUp(self): + self.user = User.objects.create(username='testuser') + self.category_data = {'name': 'Test Category', 'description': 'Test description', 'max_spend': 100.00} + self.category = BudgetCategory.objects.create(owner=self.user, **self.category_data) + self.serializer = BudgetCategorySerializer(instance=self.category) + + def test_contains_expected_fields(self): + data = self.serializer.data + self.assertCountEqual(data.keys(), ['id', 'name', 'description', 'max_spend']) + + def test_name_field_content(self): + data = self.serializer.data + eq_(data['name'], self.category_data['name']) + + def test_owner_field_is_read_only(self): + data = {'name': 'Updated Category', 'description': 'Updated description', 'max_spend': 200.00, 'owner': self.user.id} + serializer = BudgetCategorySerializer(instance=self.category, data=data) + self.assertTrue(serializer.is_valid()) + category = serializer.save() + self.assertEqual(category.owner, self.user) + self.assertNotIn('owner', serializer.data) + +class TestTransactionSerializer(TestCase): + def setUp(self): + self.user = User.objects.create(username='testuser') + self.category = BudgetCategory.objects.create(name='Test Category', description='Test description', max_spend=100.00, owner=self.user) + self.transaction_data = { + 'owner': self.user, + 'category': self.category, + 'amount': 50.00, + 'description': 'Test transaction' + } + self.transaction = Transaction.objects.create(**self.transaction_data) + self.serializer = TransactionSerializer(instance=self.transaction) + + def test_contains_expected_fields(self): + data = self.serializer.data + self.assertCountEqual(data.keys(), ['id', 'category', 'amount', 'description', 'date']) + + def test_amount_field_content(self): + data = self.serializer.data + eq_(float(data['amount']), self.transaction_data['amount']) + + def test_owner_field_is_read_only(self): + data = { + 'category': self.category.id, + 'amount': 75.00, + 'description': 'Updated transaction', + 'owner': self.user.id + } + serializer = TransactionSerializer(instance=self.transaction, data=data) + self.assertTrue(serializer.is_valid()) + transaction = serializer.save() + self.assertEqual(transaction.owner, self.user) + self.assertNotIn('owner', serializer.data) \ No newline at end of file diff --git a/flite/core/test/test_tasks.py b/flite/core/test/test_tasks.py new file mode 100644 index 0000000..efc47e0 --- /dev/null +++ b/flite/core/test/test_tasks.py @@ -0,0 +1,34 @@ + + +from django.test import TestCase +from django.core import mail +from flite.users.models import User +from flite.core.models import BudgetCategory, Transaction +from flite.core.tasks import check_budget_threshold +from decimal import Decimal + +class TestCheckBudgetThresholdTask(TestCase): + def setUp(self): + self.user = User.objects.create_user('testuser', 'test@example.com', 'password') + self.category = BudgetCategory.objects.create(name='Test Category', description='Test description', max_spend=100.00, owner=self.user) + + def test_check_budget_threshold_below_threshold(self): + Transaction.objects.create(owner=self.user, category=self.category, amount=Decimal('40.00'), description='Test transaction') + self.assertEqual(len(mail.outbox), 0) + + def test_check_budget_threshold_multiple_categories(self): + transaction = Transaction.objects.create(owner=self.user, category=self.category, amount=Decimal('50.00')) + category2 = BudgetCategory.objects.create(name='Test Category 2', description='Test description 2', max_spend=200.00, owner=self.user) + Transaction.objects.create(owner=self.user, category=self.category, amount=Decimal('60.00'), description='Test transaction') + + def test_check_budget_threshold_no_transactions(self): + empty_category = BudgetCategory.objects.create(name='Empty Category', description='Empty description', max_spend=100.00, owner=self.user) + transaction = Transaction.objects.create(owner=self.user, category=empty_category, amount=10.00, description='Test transaction') + check_budget_threshold(transaction) + self.assertEqual(len(mail.outbox), 0) + + def test_check_budget_threshold_below_fifty_percent(self): + Transaction.objects.create(owner=self.user, category=self.category, amount=20.00, description='Test transaction') + transaction = Transaction.objects.create(owner=self.user, category=self.category, amount=10.00, description='Test transaction') + check_budget_threshold(transaction) + self.assertEqual(len(mail.outbox), 0) \ No newline at end of file diff --git a/flite/core/test/test_urls.py b/flite/core/test/test_urls.py new file mode 100644 index 0000000..74b2fea --- /dev/null +++ b/flite/core/test/test_urls.py @@ -0,0 +1,21 @@ +from django.test import SimpleTestCase +from django.urls import reverse, resolve +from flite.core import views + +class TestUrls(SimpleTestCase): + + def test_budget_category_list_url(self): + path = reverse('budget_category_list') + self.assertEqual(resolve(path).func, views.budget_category_list) + + def test_budget_category_detail_url(self): + path = reverse('budget_category_detail', kwargs={'pk': 1}) + self.assertEqual(resolve(path).func, views.budget_category_detail) + + def test_transaction_list_url(self): + path = reverse('transaction_list') + self.assertEqual(resolve(path).func, views.transaction_list) + + def test_transaction_detail_url(self): + path = reverse('transaction_detail', kwargs={'pk': 1}) + self.assertEqual(resolve(path).func, views.transaction_detail) \ No newline at end of file diff --git a/flite/core/test/test_views.py b/flite/core/test/test_views.py new file mode 100644 index 0000000..d78f3cd --- /dev/null +++ b/flite/core/test/test_views.py @@ -0,0 +1,144 @@ +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate +from rest_framework import status +from flite.users.models import User +from flite.core.views import budget_category_list, budget_category_detail, transaction_list, transaction_detail +from flite.core.models import BudgetCategory, Transaction + +class TestBudgetCategoryViews(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = User.objects.create_user('testuser', 'test@example.com', 'password') + self.category = BudgetCategory.objects.create(name='Test Category', description='Test description', max_spend=100.00, owner=self.user) + + def test_budget_category_list_get(self): + request = self.factory.get('/budget-categories/', format='json') + force_authenticate(request, user=self.user) + response = budget_category_list(request) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_budget_category_list_post(self): + data = {'name': 'New Category', 'description': 'New description', 'max_spend': 200.00, 'owner': self.user.id} + request = self.factory.post('/budget-categories/', data, format='json') + force_authenticate(request, user=self.user) + response = budget_category_list(request) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(BudgetCategory.objects.count(), 2) + + def test_budget_category_detail_get(self): + request = self.factory.get('/budget-categories/{}/'.format(self.category.pk), format='json') + force_authenticate(request, user=self.user) + response = budget_category_detail(request, pk=self.category.pk) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['name'], self.category.name) + + def test_budget_category_detail_put(self): + data = {'name': 'Updated Category', 'description': 'Updated description', 'max_spend': 150.00} + request = self.factory.put('/budget-categories/{}/'.format(self.category.pk), data, format='json') + force_authenticate(request, user=self.user) + response = budget_category_detail(request, pk=self.category.pk) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.category.refresh_from_db() + self.assertEqual(self.category.name, 'Updated Category') + + def test_budget_category_detail_delete(self): + request = self.factory.delete('/budget-categories/{}/'.format(self.category.pk), format='json') + force_authenticate(request, user=self.user) + response = budget_category_detail(request, pk=self.category.pk) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(BudgetCategory.objects.count(), 0) + +class TestTransactionViews(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = User.objects.create_user('testuser', 'test@example.com', 'password') + self.category = BudgetCategory.objects.create(name='Test Category', description='Test description', max_spend=100.00, owner=self.user) + self.transaction = Transaction.objects.create(owner=self.user, category=self.category, amount=50.00, description='Test transaction') + + def test_transaction_list_get(self): + request = self.factory.get('/transactions/', format='json') + force_authenticate(request, user=self.user) + response = transaction_list(request) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_transaction_list_post(self): + data = {'owner': self.user.pk, 'category': self.category.pk, 'amount': 20.00, 'description': 'New transaction'} + request = self.factory.post('/transactions/', data, format='json') + force_authenticate(request, user=self.user) + response = transaction_list(request) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Transaction.objects.count(), 2) + + def test_transaction_detail_get(self): + request = self.factory.get('/transactions/{}/'.format(self.transaction.pk), format='json') + force_authenticate(request, user=self.user) + response = transaction_detail(request, pk=self.transaction.pk) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(float(response.data['amount']), float(self.transaction.amount)) + + def test_transaction_detail_put(self): + data = { + 'owner': self.user.pk, + 'category': self.category.pk, + 'amount': 30.00, + 'description': 'Updated transaction' + } + request = self.factory.put('/transactions/{}/'.format(self.transaction.pk), data, format='json') + force_authenticate(request, user=self.user) + response = transaction_detail(request, pk=self.transaction.pk) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.transaction.refresh_from_db() + self.assertEqual(self.transaction.amount, 30.00) + + def test_transaction_detail_delete(self): + request = self.factory.delete('/transactions/{}/'.format(self.transaction.pk), format='json') + force_authenticate(request, user=self.user) + response = transaction_detail(request, pk=self.transaction.pk) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Transaction.objects.count(), 0) + + def test_budget_category_list_post_with_invalid_data(self): + data = {'name': '', 'description': 'New description', 'max_spend': 'invalid'} + request = self.factory.post('/budget-categories/', data, format='json') + force_authenticate(request, user=self.user) + response = budget_category_list(request) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_transaction_detail_other_user(self): + other_user = User.objects.create_user('otheruser', 'other@example.com', 'password') + other_transaction = Transaction.objects.create(owner=other_user, category=self.category, amount=20.00, description='Other transaction') + + request = self.factory.get('/transactions/{}/'.format(other_transaction.pk), format='json') + force_authenticate(request, user=self.user) + response = transaction_detail(request, pk=other_transaction.pk) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_transaction_list_unauthorized(self): + request = self.factory.get('/transactions/', format='json') + response = transaction_list(request) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_transaction_detail_put_invalid_data(self): + data = { + 'owner': self.user.pk, + 'category': self.category.pk, + 'amount': 'invalid', + 'description': 'Updated transaction' + } + request = self.factory.put('/transactions/{}/'.format(self.transaction.pk), data, format='json') + force_authenticate(request, user=self.user) + response = transaction_detail(request, pk=self.transaction.pk) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_transaction_list_unauthenticated(self): + request = self.factory.get('/transactions/', format='json') + response = transaction_list(request) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + def test_budget_category_detail_invalid_id(self): + invalid_id = 999 + request = self.factory.get('/budget-categories/{}/'.format(invalid_id), format='json') + force_authenticate(request, user=self.user) + response = budget_category_detail(request, pk=invalid_id) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) \ No newline at end of file diff --git a/flite/core/tests.py b/flite/core/tests.py index 7ce503c..4929020 100644 --- a/flite/core/tests.py +++ b/flite/core/tests.py @@ -1,3 +1,2 @@ -from django.test import TestCase # Create your tests here. diff --git a/flite/core/urls.py b/flite/core/urls.py new file mode 100644 index 0000000..aa04719 --- /dev/null +++ b/flite/core/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('budget_categories/', views.budget_category_list, name='budget_category_list'), + path('budget_categories//', views.budget_category_detail, name='budget_category_detail'), + path('transactions/', views.transaction_list, name='transaction_list'), + path('transactions//', views.transaction_detail, name='transaction_detail'), +] \ No newline at end of file diff --git a/flite/core/utils.py b/flite/core/utils.py index e69de29..bb42856 100644 --- a/flite/core/utils.py +++ b/flite/core/utils.py @@ -0,0 +1,23 @@ +from functools import wraps +from drf_yasg.utils import swagger_auto_schema + +def swagger_decorator(methods=None, request_body=None, responses=None): + def decorator(func): + @wraps(func) + def decorated_func(*args, **kwargs): + return func(*args, **kwargs) + + for method in methods: + schema_kwargs = {} + if request_body: + schema_kwargs['request_body'] = request_body + if responses: + schema_kwargs['responses'] = responses + + decorated_func = swagger_auto_schema( + methods=[method], + **schema_kwargs + )(decorated_func) + + return decorated_func + return decorator \ No newline at end of file diff --git a/flite/core/views.py b/flite/core/views.py index 91ea44a..24b1203 100644 --- a/flite/core/views.py +++ b/flite/core/views.py @@ -1,3 +1,112 @@ -from django.shortcuts import render +import logging +from django.db import transaction +from rest_framework.decorators import api_view, permission_classes, authentication_classes +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.authentication import TokenAuthentication +from rest_framework.response import Response +from .models import BudgetCategory, Transaction +from .serializers import BudgetCategorySerializer, TransactionSerializer +from .utils import swagger_decorator -# Create your views here. +logger = logging.getLogger(__name__) + +def handle_exception(func): + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + logger.error(f"Error in {func.__name__}: {str(e)}") + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return wrapper + +@swagger_decorator(methods=['GET'], responses={200: BudgetCategorySerializer(many=True)}) +@swagger_decorator(methods=['POST'], request_body=BudgetCategorySerializer, responses={201: BudgetCategorySerializer()}) +@api_view(['GET', 'POST']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +@handle_exception +def budget_category_list(request): + if request.method == 'GET': + categories = BudgetCategory.objects.filter(owner=request.user) + serializer = BudgetCategorySerializer(categories, many=True) + return Response(serializer.data) + elif request.method == 'POST': + serializer = BudgetCategorySerializer(data=request.data) + if serializer.is_valid(): + serializer.save(owner=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +@swagger_decorator(methods=['GET'], responses={200: BudgetCategorySerializer()}) +@swagger_decorator(methods=['PUT'], request_body=BudgetCategorySerializer, responses={200: BudgetCategorySerializer()}) +@swagger_decorator(methods=['DELETE'], responses={204: 'No Content'}) +@api_view(['GET', 'PUT', 'DELETE']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +@handle_exception +def budget_category_detail(request, pk): + try: + category = BudgetCategory.objects.get(pk=pk, owner=request.user) + except BudgetCategory.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + if request.method == 'GET': + serializer = BudgetCategorySerializer(category) + return Response(serializer.data) + elif request.method == 'PUT': + serializer = BudgetCategorySerializer(category, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + elif request.method == 'DELETE': + category.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + +@swagger_decorator(methods=['GET'], responses={200: TransactionSerializer(many=True)}) +@swagger_decorator(methods=['POST'], request_body=TransactionSerializer, responses={201: TransactionSerializer()}) +@api_view(['GET', 'POST']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +@handle_exception +def transaction_list(request): + if request.method == 'GET': + transactions = Transaction.objects.filter(owner=request.user) + serializer = TransactionSerializer(transactions, many=True) + return Response(serializer.data) + elif request.method == 'POST': + with transaction.atomic(): + serializer = TransactionSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(owner=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +@swagger_decorator(methods=['GET'], responses={200: TransactionSerializer()}) +@swagger_decorator(methods=['PUT'], request_body=TransactionSerializer, responses={200: TransactionSerializer()}) +@swagger_decorator(methods=['DELETE'], responses={204: 'No Content'}) +@api_view(['GET', 'PUT', 'DELETE']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +@handle_exception +def transaction_detail(request, pk): + try: + transaction_obj = Transaction.objects.get(pk=pk, owner=request.user) + except Transaction.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + if request.method == 'GET': + serializer = TransactionSerializer(transaction_obj) + return Response(serializer.data) + elif request.method == 'PUT': + with transaction.atomic(): + serializer = TransactionSerializer(transaction_obj, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + elif request.method == 'DELETE': + with transaction.atomic(): + transaction_obj.delete() + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/flite/urls.py b/flite/urls.py index ed13035..7af9399 100755 --- a/flite/urls.py +++ b/flite/urls.py @@ -6,19 +6,39 @@ from rest_framework.routers import DefaultRouter from rest_framework.authtoken import views from .users.views import UserViewSet, UserCreateViewSet, SendNewPhonenumberVerifyViewSet +from drf_yasg import openapi +from drf_yasg.views import get_schema_view +from rest_framework import permissions router = DefaultRouter() router.register(r'users', UserViewSet) router.register(r'users', UserCreateViewSet) router.register(r'phone', SendNewPhonenumberVerifyViewSet) - +schema_view = get_schema_view( + openapi.Info( + title="Flite API", + default_version="v1", + description="API for Flite", + terms_of_service="https://www.google.com/policies/terms/", + contact=openapi.Contact(email="flite@cowrywise.com"), + ), + permission_classes=(permissions.AllowAny,), +) urlpatterns = [ path('admin/', admin.site.urls), # path('jet_api/', include('jet_django.urls')), - path('api/v1/', include(router.urls)), + path('api/v1/', include([ + path('', include(router.urls)), # Include URLs from your router + path('', include('flite.core.urls')), # Include URLs from your app + ])), path('api-token-auth/', views.obtain_auth_token), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), - + path( + "api/playground/", + schema_view.with_ui("swagger", cache_timeout=0), + name="schema-swagger-ui", + ), + path("docs/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), # the 'api-root' from django rest-frameworks default router # http://www.django-rest-framework.org/api-guide/routers/#defaultrouter re_path(r'^$', RedirectView.as_view(url=reverse_lazy('api-root'), permanent=False)), diff --git a/flite/users/__init__.py b/flite/users/__init__.py index e69de29..ef34d74 100644 --- a/flite/users/__init__.py +++ b/flite/users/__init__.py @@ -0,0 +1,2 @@ +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. diff --git a/flite/users/migrations/0004_alter_user_first_name.py b/flite/users/migrations/0004_alter_user_first_name.py new file mode 100644 index 0000000..a13890e --- /dev/null +++ b/flite/users/migrations/0004_alter_user_first_name.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.16 on 2024-05-01 07:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_auto_20210603_1751'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='first_name', + field=models.CharField(blank=True, max_length=150, verbose_name='first name'), + ), + ] diff --git a/flite/users/migrations/0005_auto_20240501_1146.py b/flite/users/migrations/0005_auto_20240501_1146.py new file mode 100644 index 0000000..36284e8 --- /dev/null +++ b/flite/users/migrations/0005_auto_20240501_1146.py @@ -0,0 +1,41 @@ +# Generated by Django 3.2.16 on 2024-05-01 10:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0004_alter_user_first_name'), + ] + + operations = [ + migrations.RemoveField( + model_name='allbanks', + name='created', + ), + migrations.RemoveField( + model_name='balance', + name='created', + ), + migrations.RemoveField( + model_name='newuserphoneverification', + name='created', + ), + migrations.RemoveField( + model_name='phonenumber', + name='created', + ), + migrations.RemoveField( + model_name='referral', + name='created', + ), + migrations.RemoveField( + model_name='transaction', + name='created', + ), + migrations.RemoveField( + model_name='userprofile', + name='created', + ), + ] diff --git a/flite/users/migrations/0006_auto_20240501_1147.py b/flite/users/migrations/0006_auto_20240501_1147.py new file mode 100644 index 0000000..00f9840 --- /dev/null +++ b/flite/users/migrations/0006_auto_20240501_1147.py @@ -0,0 +1,49 @@ +# Generated by Django 3.2.16 on 2024-05-01 10:47 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0005_auto_20240501_1146'), + ] + + operations = [ + migrations.AddField( + model_name='allbanks', + name='created', + field=models.DateTimeField(default=django.utils.timezone.now, editable=False), + ), + migrations.AddField( + model_name='balance', + name='created', + field=models.DateTimeField(default=django.utils.timezone.now, editable=False), + ), + migrations.AddField( + model_name='newuserphoneverification', + name='created', + field=models.DateTimeField(default=django.utils.timezone.now, editable=False), + ), + migrations.AddField( + model_name='phonenumber', + name='created', + field=models.DateTimeField(default=django.utils.timezone.now, editable=False), + ), + migrations.AddField( + model_name='referral', + name='created', + field=models.DateTimeField(default=django.utils.timezone.now, editable=False), + ), + migrations.AddField( + model_name='transaction', + name='created', + field=models.DateTimeField(default=django.utils.timezone.now, editable=False), + ), + migrations.AddField( + model_name='userprofile', + name='created', + field=models.DateTimeField(default=django.utils.timezone.now, editable=False), + ), + ] diff --git a/flite/users/models.py b/flite/users/models.py index 4862101..66e74e9 100644 --- a/flite/users/models.py +++ b/flite/users/models.py @@ -3,20 +3,22 @@ from django.conf import settings from django.dispatch import receiver from django.contrib.auth.models import AbstractUser -from django.utils.encoding import python_2_unicode_compatible from django.db.models.signals import post_save from rest_framework.authtoken.models import Token from flite.core.models import BaseModel from phonenumber_field.modelfields import PhoneNumberField from django.utils import timezone +from django.db.models import Sum -@python_2_unicode_compatible class User(AbstractUser): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) def __str__(self): return self.username + @property + def total_amount(self): + return self.transactions.aggregate(total=Sum('amount'))['total'] or 0 @receiver(post_save, sender=settings.AUTH_USER_MODEL) def create_auth_token(sender, instance=None, created=False, **kwargs): diff --git a/flite/users/test/factories.py b/flite/users/test/factories.py index f5aa2b6..93ee2c6 100644 --- a/flite/users/test/factories.py +++ b/flite/users/test/factories.py @@ -1,5 +1,5 @@ import factory - +from django.utils import timezone class UserFactory(factory.django.DjangoModelFactory): @@ -14,5 +14,6 @@ class Meta: email = factory.Faker('email') first_name = factory.Faker('first_name') last_name = factory.Faker('last_name') + last_login =timezone.now() is_active = True is_staff = False diff --git a/flite/users/views.py b/flite/users/views.py index 211a1bb..5b1abe1 100644 --- a/flite/users/views.py +++ b/flite/users/views.py @@ -4,7 +4,6 @@ from .models import User, NewUserPhoneVerification from .permissions import IsUserOrReadOnly from .serializers import CreateUserSerializer, UserSerializer, SendNewPhonenumberSerializer -from rest_framework.views import APIView from . import utils class UserViewSet(mixins.RetrieveModelMixin, diff --git a/requirements.txt b/requirements.txt index e943982..12bbe77 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,41 +1,85 @@ -# Core -pytz==2018.9 -Django==2.1.9 -django-configurations==2.1 -gunicorn==19.9.0 -newrelic==4.12.0.113 - -# For the persistence stores -psycopg2-binary==2.7.7 +amqp==5.2.0 +asgiref==3.8.1 +asttokens==2.4.1 +backcall==0.2.0 +backports.zoneinfo==0.2.1 +billiard==4.2.0 +blessings==1.7 +boto3==1.9.93 +botocore==1.12.253 +celery==5.4.0 +click==8.1.7 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +coverage==4.5.2 +decorator==5.1.1 dj-database-url==0.5.0 - -# Model Tools +Django==3.2.16 +django-configurations==2.2 +django-environ==0.4.5 +django-extensions==3.1.0 +django-filter==23.5 django-model-utils==3.1.2 -django_unique_upload==0.2.1 - -# Rest apis -djangorestframework==3.9.1 -Markdown==3.0.1 -django-filter==2.1.0 - -# Developer Tools +django-nose==1.4.6 +django-phonenumber-field==7.3.0 +django-storages==1.7.1 +django-unique-upload==0.2.1 +djangorestframework==3.11.0 +docutils==0.15.2 +drf-yasg==1.21.7 +entrypoints==0.3 +executing==2.0.1 +factory-boy==2.11.1 +Faker==25.0.0 +flake8==3.7.5 +flower==2.0.1 +gunicorn==19.9.0 +humanize==4.9.0 +inflection==0.5.1 ipdb==0.11 -ipython==7.2.0 +ipython==8.12.3 +jedi==0.19.1 +Jinja2==3.1.3 +jmespath==0.10.0 +kombu==5.3.7 +livereload==2.6.3 +Markdown==3.0.1 +MarkupSafe==2.1.5 +matplotlib-inline==0.1.7 +mccabe==0.6.1 mkdocs==1.0.4 -flake8==3.7.5 - -# Testing mock==2.0.0 -factory-boy==2.11.1 -django-nose==1.4.6 +newrelic==4.12.0.113 +nose==1.3.7 nose-progressive==1.5.2 -coverage==4.5.2 - -# Static and Media Storage -django-storages==1.7.1 -boto3==1.9.93 -django-extensions==2.2.1 -django-environ==0.4.5 -phonenumbers -django-phonenumber-field -whitenoise \ No newline at end of file +packaging==24.0 +parso==0.8.4 +pbr==6.0.0 +pexpect==4.9.0 +phonenumbers==8.13.35 +pickleshare==0.7.5 +prometheus_client==0.20.0 +prompt-toolkit==3.0.43 +psycopg2-binary==2.9.9 +ptyprocess==0.7.0 +pure-eval==0.2.2 +pycodestyle==2.5.0 +pyflakes==2.1.1 +Pygments==2.17.2 +python-dateutil==2.9.0.post0 +pytz==2024.1 +PyYAML==6.0.1 +s3transfer==0.2.1 +six==1.16.0 +sqlparse==0.5.0 +stack-data==0.6.3 +tornado==6.4 +traitlets==5.14.3 +typing_extensions==4.11.0 +tzdata==2024.1 +uritemplate==4.1.1 +urllib3==1.25.11 +vine==5.1.0 +wcwidth==0.2.13 +whitenoise==6.6.0