├── VERSION ├── swiftwind ├── bills │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_delete_bill.py │ │ └── 0001_initial.py │ ├── tests.py │ ├── admin.py │ ├── views.py │ └── models.py ├── core │ ├── __init__.py │ ├── models.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── run_scheduler.py │ │ │ └── swiftwind_create_accounts.py │ ├── migrations │ │ └── __init__.py │ ├── templatetags │ │ ├── __init__.py │ │ ├── nav.py │ │ └── swiftwind_utilities.py │ ├── templates │ │ ├── hordak │ │ │ └── base.html │ │ ├── swiftwind │ │ │ ├── base.html │ │ │ └── base_email.html │ │ ├── adminlte │ │ │ └── lib │ │ │ │ ├── _main_header.html │ │ │ │ └── _main_sidebar.html │ │ └── registration │ │ │ └── login.html │ ├── exceptions.py │ ├── admin.py │ ├── static │ │ └── core │ │ │ └── css │ │ │ └── common.css │ └── tests.py ├── costs │ ├── __init__.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── enact_costs.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0017_recurringcost_archived.py │ │ ├── 0007_auto_20161009_0106.py │ │ ├── 0016_auto_20171207_1258.py │ │ ├── 0010_auto_20161009_1225.py │ │ ├── 0012_auto_20171129_2247.py │ │ ├── 0003_check_one_off_must_be_type_normal.py │ │ ├── 0015_auto_20171206_0145.py │ │ ├── 0004_check_total_billing_cycles_over_zero.py │ │ ├── 0011_check_fixed_amount_requires_type_normal.py │ │ ├── 0014_allow_initial_billing_cycle_and_disabled.py │ │ ├── 0009_check_disabled_xor_billing_cycle.py │ │ ├── 0006_auto_20161009_0019.py │ │ ├── 0013_auto_20171203_1516.py │ │ ├── 0005_check_recurring_costs_have_splits.py │ │ ├── 0008_check_cannot_create_recurred_cost_for_disabled_cost.py │ │ ├── 0002_auto_20161008_1841.py │ │ └── 0001_initial.py │ ├── templates │ │ └── costs │ │ │ ├── delete_recurring.html │ │ │ ├── create_recurring.html │ │ │ ├── one_off.html │ │ │ ├── create_one_off.html │ │ │ └── delete_oneoff.html │ ├── exceptions.py │ ├── admin.py │ ├── tasks.py │ ├── urls.py │ ├── plan.md │ ├── forms.py │ └── views.py ├── accounts │ ├── __init__.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── tellerio_import.py │ ├── migrations │ │ └── __init__.py │ ├── admin.py │ ├── templates │ │ ├── hordak │ │ │ └── accounts │ │ │ │ ├── account_list.html │ │ │ │ ├── account_transactions.html │ │ │ │ ├── account_create.html │ │ │ │ └── account_update.html │ │ └── accounts │ │ │ ├── reconciliation_required_email.html │ │ │ ├── statement_email.html │ │ │ └── overview.html │ ├── models.py │ ├── urls.py │ ├── tests.py │ ├── tasks.py │ └── views.py ├── dashboard │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── admin.py │ ├── urls.py │ ├── tests.py │ └── views.py ├── housemates │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_auto_20161001_1707.py │ │ └── 0001_initial.py │ ├── admin.py │ ├── apps.py │ ├── urls.py │ ├── models.py │ ├── templates │ │ └── housemates │ │ │ ├── update.html │ │ │ ├── housemates_required_error.html │ │ │ ├── list.html │ │ │ └── create.html │ └── views.py ├── utilities │ ├── __init__.py │ ├── site.py │ ├── emails.py │ ├── testing.py │ └── formsets.py ├── billing_cycle │ ├── __init__.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── populate_billing_cycles.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0004_auto_20161001_1707.py │ │ ├── 0006_billingcycle_statements_sent.py │ │ ├── 0002_check_non_overlapping.py │ │ ├── 0005_auto_20171203_1516.py │ │ ├── 0001_initial.py │ │ └── 0003_check_adjacent.py │ ├── admin.py │ ├── exceptions.py │ ├── apps.py │ ├── tasks.py │ ├── urls.py │ ├── views.py │ ├── cycles.py │ └── templates │ │ └── billing_cycle │ │ └── list.html ├── system_setup │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── admin.py │ ├── apps.py │ ├── urls.py │ ├── templates │ │ └── setup │ │ │ └── index.html │ ├── middleware.py │ ├── views.py │ ├── forms.py │ └── tests.py ├── transactions │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── tests.py │ ├── admin.py │ ├── views.py │ ├── apps.py │ └── templates │ │ └── hordak │ │ └── transactions │ │ ├── reconcile.html │ │ ├── transaction_create.html │ │ └── currency_trade.html ├── settings │ ├── migrations │ │ ├── __init__.py │ │ ├── 0005_settings_from_email.py │ │ ├── 0002_settings_tellerio_enable.py │ │ ├── 0003_auto_20171206_0145.py │ │ └── 0004_auto_20171207_0014.py │ ├── __init__.py │ ├── admin.py │ ├── tests.py │ ├── templates │ │ └── settings │ │ │ ├── general.html │ │ │ ├── email.html │ │ │ ├── technical.html │ │ │ ├── base.html │ │ │ └── teller.html │ ├── urls.py │ ├── views.py │ ├── apps.py │ ├── models.py │ └── forms.py ├── __init__.py ├── defaults.py └── urls.py ├── requirements.test.txt ├── example_project ├── requirements.txt ├── __init__.py ├── wsgi.py ├── celery.py ├── urls.py └── settings.py ├── .coveragerc ├── requirements.txt ├── deploy └── charts │ └── swiftwind │ ├── Chart.yaml │ ├── charts │ └── postgresql-0.8.4.tgz │ ├── requirements.yaml │ ├── requirements.lock │ ├── .helmignore │ ├── templates │ ├── _helpers.tpl │ ├── service.yaml │ ├── ingress.yaml │ ├── NOTES.txt │ ├── scheduler.yaml │ └── deployment.yaml │ └── values.yaml ├── .gitignore ├── .editorconfig ├── manage.py ├── MANIFEST.in ├── docker-compose.yaml ├── Dockerfile ├── LICENSE.txt ├── setup.py ├── README.rst └── .travis.yml /VERSION: -------------------------------------------------------------------------------- 1 | 0.2.0 -------------------------------------------------------------------------------- /swiftwind/bills/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/core/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/costs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/accounts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/dashboard/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/housemates/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/utilities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.test.txt: -------------------------------------------------------------------------------- 1 | freezegun 2 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/bills/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/core/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/core/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/costs/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/system_setup/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/transactions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example_project/requirements.txt: -------------------------------------------------------------------------------- 1 | redis 2 | -------------------------------------------------------------------------------- /swiftwind/accounts/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/accounts/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/core/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/dashboard/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/housemates/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/settings/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/__init__.py: -------------------------------------------------------------------------------- 1 | from . import defaults 2 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/core/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/costs/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/system_setup/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/transactions/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/accounts/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | 3 | omit = 4 | */admin.py 5 | */migrations/* 6 | -------------------------------------------------------------------------------- /swiftwind/core/templates/hordak/base.html: -------------------------------------------------------------------------------- 1 | {% extends 'swiftwind/base.html' %} 2 | -------------------------------------------------------------------------------- /swiftwind/accounts/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.contrib import admin 3 | -------------------------------------------------------------------------------- /swiftwind/core/exceptions.py: -------------------------------------------------------------------------------- 1 | class CannotCreateMultipleSettingsInstances(Exception): pass 2 | -------------------------------------------------------------------------------- /swiftwind/bills/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /swiftwind/dashboard/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /swiftwind/settings/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'swiftwind.settings.apps.SettingsConfig' 2 | -------------------------------------------------------------------------------- /swiftwind/bills/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.contrib import admin 3 | 4 | 5 | -------------------------------------------------------------------------------- /swiftwind/bills/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /swiftwind/dashboard/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.contrib import admin 3 | 4 | 5 | -------------------------------------------------------------------------------- /swiftwind/settings/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /swiftwind/settings/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /swiftwind/system_setup/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /swiftwind/transactions/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /swiftwind/transactions/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /swiftwind/housemates/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /swiftwind/system_setup/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /swiftwind/transactions/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /swiftwind/transactions/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | . 2 | git+https://git@github.com/adamcharnock/django-hordak.git@2116230#egg=django-hordak 3 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class CannotPopulateForDateOutsideExistingCycles(Exception): pass 3 | -------------------------------------------------------------------------------- /example_project/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from .celery import app as celery_app 4 | -------------------------------------------------------------------------------- /swiftwind/accounts/templates/hordak/accounts/account_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/accounts/account_list.html' %} 2 | 3 | -------------------------------------------------------------------------------- /deploy/charts/swiftwind/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | description: Swiftwind chart 3 | name: swiftwind 4 | version: 0.1.0 5 | -------------------------------------------------------------------------------- /swiftwind/accounts/templates/hordak/accounts/account_transactions.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/accounts/account_transactions.html' %} 2 | -------------------------------------------------------------------------------- /swiftwind/housemates/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class HousematesConfig(AppConfig): 5 | name = 'housemates' 6 | -------------------------------------------------------------------------------- /swiftwind/bills/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | from model_utils.models import TimeStampedModel 5 | -------------------------------------------------------------------------------- /swiftwind/system_setup/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SystemSetupConfig(AppConfig): 5 | name = 'system_setup' 6 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BillingCycleConfig(AppConfig): 5 | name = 'billing_cycle' 6 | -------------------------------------------------------------------------------- /swiftwind/transactions/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TransactionsConfig(AppConfig): 5 | name = 'transactions' 6 | -------------------------------------------------------------------------------- /deploy/charts/swiftwind/charts/postgresql-0.8.4.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcharnock/swiftwind/HEAD/deploy/charts/swiftwind/charts/postgresql-0.8.4.tgz -------------------------------------------------------------------------------- /swiftwind/system_setup/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | url(r'^$', views.SetupView.as_view(), name='index'), 7 | ] 8 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/tasks.py: -------------------------------------------------------------------------------- 1 | from celery import shared_task 2 | 3 | from .models import BillingCycle 4 | 5 | 6 | @shared_task 7 | def populate_billing_cycles(): 8 | BillingCycle.populate() 9 | -------------------------------------------------------------------------------- /swiftwind/accounts/models.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | from django.conf import settings 3 | from django.db import models 4 | 5 | # Create your models here. 6 | from model_utils.models import TimeStampedModel 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | .config.ini 4 | *.sqlite3 5 | *.pyc 6 | *.swp 7 | /static/ 8 | /.idea 9 | .bash_history 10 | /fixtures/*.live.json 11 | /build 12 | /dist 13 | /example_project/media 14 | -------------------------------------------------------------------------------- /deploy/charts/swiftwind/requirements.yaml: -------------------------------------------------------------------------------- 1 | dependencies: 2 | - name: postgresql 3 | version: 0.8.4 4 | repository: https://kubernetes-charts.storage.googleapis.com/ 5 | condition: postgresql.enabled 6 | -------------------------------------------------------------------------------- /swiftwind/settings/templates/settings/general.html: -------------------------------------------------------------------------------- 1 | {% extends 'settings/base.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block page_name %}General settings{% endblock %} 5 | {% block page_description %}{% endblock %} 6 | -------------------------------------------------------------------------------- /swiftwind/costs/templates/costs/delete_recurring.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /swiftwind/settings/templates/settings/email.html: -------------------------------------------------------------------------------- 1 | {% extends 'settings/base.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block page_name %}Email sending settings{% endblock %} 5 | {% block page_description %}{% endblock %} 6 | -------------------------------------------------------------------------------- /swiftwind/settings/templates/settings/technical.html: -------------------------------------------------------------------------------- 1 | {% extends 'settings/base.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block page_name %}General settings{% endblock %} 5 | {% block page_description %}{% endblock %} 6 | -------------------------------------------------------------------------------- /swiftwind/dashboard/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.contrib import admin 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | url(r'^$', views.DashboardView.as_view(), name='dashboard'), 8 | ] 9 | -------------------------------------------------------------------------------- /swiftwind/core/templates/swiftwind/base.html: -------------------------------------------------------------------------------- 1 | {% extends 'adminlte/base.html' %} 2 | {% load static %} 3 | 4 | {% block stylesheets %} 5 | {{ block.super }} 6 | 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{py,rst,ini}] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | -------------------------------------------------------------------------------- /swiftwind/core/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.contrib import admin 3 | 4 | import swiftwind.settings.models 5 | from . import models 6 | 7 | 8 | @admin.register(swiftwind.settings.models.Settings) 9 | class SettingsAdmin(admin.ModelAdmin): 10 | pass 11 | -------------------------------------------------------------------------------- /deploy/charts/swiftwind/requirements.lock: -------------------------------------------------------------------------------- 1 | dependencies: 2 | - name: postgresql 3 | repository: https://kubernetes-charts.storage.googleapis.com/ 4 | version: 0.8.4 5 | digest: sha256:3fc391a28884163f902e649b07cfaf9f5bbd6859538d1d84307a6ca850ac863c 6 | generated: 2017-12-06T21:10:35.031947938Z 7 | -------------------------------------------------------------------------------- /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", "example_project.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.py 2 | recursive-include fixtures *.json 3 | recursive-include swiftwind *.css 4 | recursive-include swiftwind *.html 5 | recursive-include swiftwind *.md 6 | recursive-include docs * 7 | recursive-include example_project *.txt 8 | include *.txt 9 | include *.rst 10 | include VERSION 11 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/management/commands/populate_billing_cycles.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from swiftwind.billing_cycle.models import BillingCycle 4 | 5 | 6 | class Command(BaseCommand): 7 | help = 'Populate billing cycles' 8 | 9 | def handle(self, *args, **options): 10 | BillingCycle.populate() 11 | -------------------------------------------------------------------------------- /swiftwind/core/templates/adminlte/lib/_main_header.html: -------------------------------------------------------------------------------- 1 | {% extends 'adminlte/lib/_main_header.html' %} 2 | 3 | {% block logo_href %}{% url 'dashboard:dashboard' %}{% endblock %} 4 | 5 | {% block logo_text %}Swiftwind{% endblock %} 6 | 7 | {% block logo_text_small %}SW{% endblock %} 8 | 9 | {% block logout_url %}/set-me-later{% endblock %} 10 | -------------------------------------------------------------------------------- /swiftwind/defaults.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def set_default(setting, value): 5 | if not hasattr(settings._wrapped, setting): 6 | setattr(settings._wrapped, setting, value) 7 | 8 | 9 | set_default('SWIFTWIND_BILLING_CYCLE', 'swiftwind.billing_cycle.cycles.Monthly') 10 | set_default('SWIFTWIND_BILLING_CYCLE_YEARS', 1) 11 | -------------------------------------------------------------------------------- /swiftwind/housemates/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.contrib import admin 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | url(r'^$', views.HousemateListView.as_view(), name='list'), 8 | url(r'^create/$', views.HousemateCreateView.as_view(), name='create'), 9 | url(r'^update/(?P.+)/$', views.HousemateUpdateView.as_view(), name='update'), 10 | ] 11 | -------------------------------------------------------------------------------- /swiftwind/costs/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class CannotEnactUnenactableRecurringCostError(Exception): pass 4 | 5 | 6 | class CannotRecreateTransactionOnRecurredCost(Exception): pass 7 | 8 | 9 | class NoSplitsFoundForRecurringCost(Exception): pass 10 | 11 | 12 | class ProvidedBillingCycleBeginsBeforeInitialBillingCycle(Exception): pass 13 | 14 | 15 | class RecurringCostAlreadyEnactedForBillingCycle(Exception): pass 16 | -------------------------------------------------------------------------------- /swiftwind/utilities/site.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sites.models import Site 2 | from swiftwind.settings.models import Settings 3 | 4 | 5 | def get_site_root(): 6 | """ 7 | 8 | Returns: (str) E.g. "https://mydomain.com" 9 | 10 | """ 11 | site = Site.objects.get() 12 | settings = Settings.objects.get() 13 | protocol = 'https' if settings.use_https else 'http' 14 | return '{}://{}'.format(protocol, site.domain) 15 | -------------------------------------------------------------------------------- /deploy/charts/swiftwind/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | -------------------------------------------------------------------------------- /swiftwind/bills/migrations/0002_delete_bill.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-09-24 13:17 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('bills', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.DeleteModel( 16 | name='Bill', 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /swiftwind/settings/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.contrib import admin 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | url(r'^$', views.GeneralSettingsView.as_view(), name='general'), 8 | url(r'^technical/$', views.TechnicalSettingsView.as_view(), name='technical'), 9 | url(r'^teller/$', views.TellerSettingsView.as_view(), name='teller'), 10 | url(r'^email/$', views.EmailSettingsView.as_view(), name='email'), 11 | ] 12 | -------------------------------------------------------------------------------- /swiftwind/costs/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.contrib import admin 3 | 4 | from .models import RecurringCost, RecurringCostSplit 5 | 6 | 7 | class RecurringCostSplitInline(admin.TabularInline): 8 | model = RecurringCostSplit 9 | 10 | 11 | @admin.register(RecurringCost) 12 | class RecurringCostAdmin(admin.ModelAdmin): 13 | list_display = ['uuid', 'to_account', 'disabled'] 14 | inlines = [ 15 | RecurringCostSplitInline, 16 | ] 17 | -------------------------------------------------------------------------------- /swiftwind/accounts/management/commands/tellerio_import.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | 3 | from swiftwind.accounts.tasks import import_tellerio 4 | 5 | 6 | class Command(BaseCommand): 7 | help = 'Import bank statements from teller.io. Configure in settings.' 8 | 9 | def handle(self, *args, **options): 10 | done = import_tellerio() 11 | if not done: 12 | raise CommandError('teller.io imports are not enabled. Enable them in the web interface.') 13 | -------------------------------------------------------------------------------- /example_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example_project 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/1.10/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | from dj_static import Cling, MediaCling 14 | 15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") 16 | 17 | application = Cling(MediaCling(get_wsgi_application())) 18 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/migrations/0004_auto_20161001_1707.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-10-01 17:07 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('billing_cycle', '0003_check_adjacent'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name='billingcycle', 17 | options={'ordering': ['date_range']}, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | url(r'^$', views.BillingCycleListView.as_view(), name='list'), 7 | url(r'^enact/(?P.+)/$', views.CreateTransactionsView.as_view(), name='enact'), 8 | url(r'^reenact/(?P.+)/$', views.RecreateTransactionsView.as_view(), name='reenact'), 9 | url(r'^unenact/(?P.+)/$', views.DeleteTransactionsView.as_view(), name='unenact'), 10 | url(r'^send/(?P.+)/$', views.SendNotificationsView.as_view(), name='send'), 11 | ] 12 | -------------------------------------------------------------------------------- /swiftwind/core/management/commands/run_scheduler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import schedule 3 | import time 4 | from django.core.management.base import BaseCommand 5 | 6 | from swiftwind.accounts.tasks import import_tellerio 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class Command(BaseCommand): 12 | help = 'Run the scheduler which executes periodic tasks' 13 | 14 | def handle(self, *args, **options): 15 | 16 | schedule.every().hour.do(import_tellerio) 17 | 18 | while True: 19 | schedule.run_pending() 20 | time.sleep(1) 21 | -------------------------------------------------------------------------------- /example_project/celery.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import os 4 | import sys 5 | 6 | from celery import Celery 7 | 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example_project.settings') 9 | sys.path.insert(0, '/Users/adam/Projects/swiftwind') 10 | 11 | from django.conf import settings # noqa 12 | 13 | app = Celery('proj') 14 | 15 | app.config_from_object('django.conf:settings') 16 | app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) 17 | 18 | 19 | @app.task(bind=True) 20 | def debug_task(self): 21 | print('Request: {0!r}'.format(self.request)) 22 | 23 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/0017_recurringcost_archived.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2018-02-09 15:39 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('costs', '0016_auto_20171207_1258'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='recurringcost', 17 | name='archived', 18 | field=models.BooleanField(default=False), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /swiftwind/utilities/emails.py: -------------------------------------------------------------------------------- 1 | from django.test import RequestFactory 2 | 3 | from .site import get_site_root 4 | 5 | 6 | class EmailViewMixin(object): 7 | 8 | def get_context_data(self, **kwargs): 9 | return super().get_context_data( 10 | site_root=get_site_root() 11 | ) 12 | 13 | @classmethod 14 | def get_html(cls, **kwargs): 15 | fake_request = RequestFactory().get('/foo') 16 | view = cls.as_view() 17 | response = view(fake_request, **kwargs) 18 | response.render() 19 | return response.content.decode('utf8') 20 | -------------------------------------------------------------------------------- /swiftwind/core/templatetags/nav.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from hordak.models import Account, StatementLine 3 | 4 | register = template.Library() 5 | 6 | @register.simple_tag 7 | def housemate_accounts(): 8 | return Account.objects.filter(children=None).filter(parent__name='Housemate Income') 9 | 10 | 11 | @register.simple_tag 12 | def other_accounts(): 13 | return Account.objects.filter(children=None).exclude(parent__name='Housemate Income') 14 | 15 | 16 | @register.simple_tag 17 | def total_unreconciled(): 18 | return StatementLine.objects.filter(transaction=None).count() 19 | -------------------------------------------------------------------------------- /swiftwind/dashboard/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import reverse 3 | 4 | from swiftwind.core.management.commands.swiftwind_create_accounts import Command 5 | from swiftwind.utilities.testing import DataProvider 6 | 7 | 8 | class DashboardViewTestCase(DataProvider, TestCase): 9 | 10 | def setUp(self): 11 | self.url = reverse('dashboard:dashboard') 12 | Command().handle(currency='GBP') 13 | 14 | def test_get(self): 15 | self.login() 16 | response = self.client.get(self.url) 17 | self.assertEqual(response.status_code, 200) 18 | -------------------------------------------------------------------------------- /swiftwind/settings/migrations/0005_settings_from_email.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-12-07 00:29 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('settings', '0004_auto_20171207_0014'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='settings', 17 | name='from_email', 18 | field=models.EmailField(blank=True, default='', max_length=254), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /swiftwind/housemates/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django_smalluuid.models import SmallUUIDField, uuid_default 4 | from hordak.models import Account 5 | 6 | 7 | class Housemate(models.Model): 8 | uuid = SmallUUIDField(default=uuid_default(), editable=False) 9 | account = models.OneToOneField(Account, related_name='housemate', unique=True) 10 | user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, 11 | related_name='housemate', blank=True, null=True, 12 | unique=True) 13 | 14 | -------------------------------------------------------------------------------- /deploy/charts/swiftwind/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "swiftwind.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | */}} 13 | {{- define "swiftwind.fullname" -}} 14 | {{- $name := default .Chart.Name .Values.nameOverride -}} 15 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 16 | {{- end -}} 17 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/0007_auto_20161009_0106.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-10-09 01:06 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('costs', '0006_auto_20161009_0019'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='recurringcost', 17 | name='fixed_amount', 18 | field=models.DecimalField(blank=True, decimal_places=2, max_digits=13, null=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /swiftwind/settings/migrations/0002_settings_tellerio_enable.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-12-05 15:08 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('settings', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='settings', 17 | name='tellerio_enable', 18 | field=models.BooleanField(default=False, help_text='Enable daily imports from teller.io'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /swiftwind/costs/templates/costs/create_recurring.html: -------------------------------------------------------------------------------- 1 | {% extends 'swiftwind/base.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block page_name %}Create a Recurring Cost{% endblock %} 5 | {% block page_description %}A recurring cost will be billed to all housemates every billing cycle{% endblock %} 6 | 7 | {% block content %} 8 |
9 | {% csrf_token %} 10 | {% bootstrap_form form layout='horizontal' %} 11 | 12 |
13 | 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /swiftwind/settings/migrations/0003_auto_20171206_0145.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-12-06 01:45 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('settings', '0002_settings_tellerio_enable'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='settings', 17 | name='tellerio_enable', 18 | field=models.BooleanField(default=False, verbose_name='Enable daily teller.io imports'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | db: 4 | image: postgres:10.1 5 | expose: 6 | - "5432" 7 | volumes: 8 | - pgdata:/var/lib/postgresql/data 9 | 10 | redis: 11 | image: redis:3 12 | expose: 13 | - "6379" 14 | 15 | app: 16 | build: . 17 | environment: 18 | - DATABASE_URL=postgres://postgres@db/postgres 19 | links: 20 | - db 21 | - redis 22 | ports: 23 | - "8000:8000" 24 | expose: 25 | - "8000" 26 | command: gunicorn --bind 0.0.0.0:8000 example_project.wsgi 27 | volumes: 28 | - media:/swiftwind/.media 29 | 30 | volumes: 31 | pgdata: 32 | media: 33 | -------------------------------------------------------------------------------- /deploy/charts/swiftwind/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ template "swiftwind.fullname" . }} 5 | labels: 6 | app: {{ template "swiftwind.name" . }} 7 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} 8 | release: {{ .Release.Name }} 9 | heritage: {{ .Release.Service }} 10 | spec: 11 | type: {{ .Values.service.type }} 12 | ports: 13 | - port: {{ .Values.service.externalPort }} 14 | targetPort: {{ .Values.service.internalPort }} 15 | protocol: TCP 16 | name: {{ .Values.service.name }} 17 | selector: 18 | app: {{ template "swiftwind.name" . }} 19 | release: {{ .Release.Name }} 20 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/migrations/0006_billingcycle_statements_sent.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-12-03 15:18 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('billing_cycle', '0005_auto_20171203_1516'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='billingcycle', 17 | name='statements_sent', 18 | field=models.BooleanField(default=False, help_text='Have we sent housemates their statements for this billing cycle?'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /swiftwind/housemates/templates/housemates/update.html: -------------------------------------------------------------------------------- 1 | {% extends 'swiftwind/base.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block page_name %}Edit Housemate{% endblock %} 5 | {% block page_description %}Modify a housemate's details{% endblock %} 6 | 7 | {% block content %} 8 |
9 | {% csrf_token %} 10 | 11 | {% bootstrap_form form layout='horizontal' %} 12 | 13 | 14 | Cancel 15 |
16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /swiftwind/accounts/templates/hordak/accounts/account_create.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/accounts/account_create.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block page_name %}Create account{% endblock %} 5 | {% block page_description %}Create a new account{% endblock %} 6 | 7 | {% block content %} 8 |
9 | {% csrf_token %} 10 | {% bootstrap_form form layout='horizontal' %} 11 | 12 |

13 | 14 | Cancel 15 |

16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /swiftwind/accounts/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | url(r'^$', views.OverviewView.as_view(), name='overview'), 7 | url(r'^housemate/(?P[^/]*)/$', views.HousemateStatementView.as_view(), name='housemate_statement'), 8 | url(r'^housemate/(?P.*)/(?P\d{4}-\d{2}-\d{2})/$', views.HousemateStatementView.as_view(), name='housemate_statement_historical'), 9 | url(r'^email/statement/(?P.*)/(?P\d{4}-\d{2}-\d{2})/$', views.StatementEmailView.as_view(), name='housemate_statement_email'), 10 | url(r'^email/reminder/$', views.ReconciliationRequiredEmailView.as_view(), name='housemate_reconciliation_required_email'), 11 | ] 12 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/0016_auto_20171207_1258.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-12-07 12:58 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('costs', '0015_auto_20171206_0145'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='recurredcost', 18 | name='transaction', 19 | field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='recurred_cost', to='hordak.Transaction'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/0010_auto_20161009_1225.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-10-09 12:25 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('costs', '0009_check_disabled_xor_billing_cycle'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='recurringcost', 18 | name='initial_billing_cycle', 19 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='billing_cycle.BillingCycle'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/0012_auto_20171129_2247.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-11-29 22:47 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('costs', '0011_check_fixed_amount_requires_type_normal'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='recurredcost', 18 | name='transaction', 19 | field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='recurred_cost', to='hordak.Transaction'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /swiftwind/accounts/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from swiftwind.accounts.views import StatementEmailView 4 | from swiftwind.billing_cycle.models import BillingCycle 5 | from swiftwind.utilities.testing import DataProvider 6 | 7 | 8 | class StatementEmailViewTestCase(DataProvider, TestCase): 9 | 10 | def setUp(self): 11 | self.login() 12 | 13 | def test_get_html(self): 14 | housemate = self.housemate() 15 | billing_cycle = BillingCycle.objects.create( 16 | date_range=['2000-01-01', '2000-02-01'], 17 | ) 18 | billing_cycle.refresh_from_db() 19 | html = StatementEmailView.get_html(uuid=housemate.uuid, date='2000-01-01') 20 | self.assertIn('', html) 21 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/migrations/0002_check_non_overlapping.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-09-30 11:50 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('billing_cycle', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RunSQL( 16 | """ 17 | ALTER TABLE billing_cycle_billingcycle ADD CONSTRAINT non_overlapping_billing_cycles 18 | EXCLUDE USING GIST (date_range WITH &&) 19 | """, 20 | "ALTER TABLE billing_cycle_billingcycle DROP CONSTRAINT non_overlapping_billing_cycles" 21 | ) 22 | ] 23 | -------------------------------------------------------------------------------- /swiftwind/system_setup/templates/setup/index.html: -------------------------------------------------------------------------------- 1 | {% extends "adminlte/login.html" %} 2 | {% load i18n static bootstrap3 %} 3 | 4 | {% block body_class %}login-page{% endblock %} 5 | 6 | {% block logo_text %}Swiftwind Setup{% endblock %} 7 | 8 | {% block login_form %} 9 |

10 | Welcome to your new Swiftwind installation. We need a 11 | little information in order to get everything setup. 12 | We'll also set you up as a housemate and administrator. 13 |

14 |
15 | {% csrf_token %} 16 | {% bootstrap_form form %} 17 | 18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /deploy/charts/swiftwind/values.yaml: -------------------------------------------------------------------------------- 1 | replicaCount: 1 2 | image: 3 | repository: adamcharnock/swiftwind 4 | tag: latest 5 | pullPolicy: IfNotPresent 6 | service: 7 | name: swiftwind 8 | type: ClusterIP 9 | externalPort: 80 10 | internalPort: 8000 11 | ingress: 12 | # NOTE: Ingress disabled by default: 13 | enabled: false 14 | 15 | # Used to create an Ingress record. 16 | hosts: 17 | - swiftwind.local 18 | annotations: 19 | kubernetes.io/ingress.class: nginx 20 | kubernetes.io/tls-acme: "true" 21 | tls: 22 | # Secrets must be manually created in the namespace. 23 | - secretName: swiftwind-tls 24 | hosts: 25 | - swiftwind.local 26 | 27 | postgresql: 28 | enabled: true 29 | postgresDatabase: swiftwind 30 | -------------------------------------------------------------------------------- /swiftwind/costs/tasks.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from celery import shared_task 4 | from django.db import transaction 5 | 6 | from swiftwind.billing_cycle.models import BillingCycle 7 | from swiftwind.costs.models import RecurringCost 8 | 9 | 10 | @shared_task 11 | @transaction.atomic() 12 | def enact_costs(as_of=None): 13 | if as_of is None: 14 | as_of = date.today() 15 | for billing_cycle in BillingCycle.objects.filter(start_date__lt=as_of, transactions_created=False): 16 | billing_cycle.enact_all_costs() 17 | 18 | 19 | @shared_task 20 | @transaction.atomic() 21 | def disable_costs(): 22 | """Disable any costs that have completed all their billing cycles""" 23 | RecurringCost.objects.all().disable_if_done() 24 | 25 | 26 | -------------------------------------------------------------------------------- /swiftwind/housemates/migrations/0002_auto_20161001_1707.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-10-01 17:07 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('housemates', '0001_initial'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name='housemate', 19 | name='user', 20 | field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='housemate', to=settings.AUTH_USER_MODEL), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/0003_check_one_off_must_be_type_normal.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-10-08 19:28 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('costs', '0002_auto_20161008_1841'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RunSQL( 16 | """ 17 | ALTER TABLE costs_recurringcost ADD CONSTRAINT one_off_costs_must_be_type_normal 18 | CHECK ("type" = 'normal' OR total_billing_cycles IS NULL) 19 | """, 20 | "ALTER TABLE costs_recurringcost DROP CONSTRAINT one_off_costs_must_be_type_normal" 21 | ) 22 | ] 23 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/0015_auto_20171206_0145.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-12-06 01:45 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('costs', '0014_allow_initial_billing_cycle_and_disabled'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='recurringcost', 18 | name='initial_billing_cycle', 19 | field=models.ForeignKey(default=-1, on_delete=django.db.models.deletion.CASCADE, to='billing_cycle.BillingCycle'), 20 | preserve_default=False, 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/migrations/0005_auto_20171203_1516.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-12-03 15:16 3 | from __future__ import unicode_literals 4 | 5 | import django.contrib.postgres.fields.ranges 6 | from django.db import migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('billing_cycle', '0004_auto_20161001_1707'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='billingcycle', 18 | name='date_range', 19 | field=django.contrib.postgres.fields.ranges.DateRangeField(db_index=True, help_text='The start and end date of this billing cycle. May not overlay with any other billing cycles.'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/0004_check_total_billing_cycles_over_zero.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-10-08 19:34 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('costs', '0003_check_one_off_must_be_type_normal'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RunSQL( 16 | """ 17 | ALTER TABLE costs_recurringcost ADD CONSTRAINT check_total_billing_cycles_over_zero 18 | CHECK (total_billing_cycles IS NULL or total_billing_cycles > 0) 19 | """, 20 | "ALTER TABLE costs_recurringcost DROP CONSTRAINT check_total_billing_cycles_over_zero" 21 | ) 22 | ] 23 | -------------------------------------------------------------------------------- /swiftwind/settings/templates/settings/base.html: -------------------------------------------------------------------------------- 1 | {% extends 'swiftwind/base.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |
9 |
10 | {% csrf_token %} 11 | {% block settings_form %} 12 | {% bootstrap_form form %} 13 | {% endblock %} 14 | 15 |
16 |
17 |
18 |
19 |
20 | 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /swiftwind/costs/templates/costs/one_off.html: -------------------------------------------------------------------------------- 1 | {% extends 'costs/recurring.html' %} 2 | {% load bootstrap3 %} 3 | {% load swiftwind_utilities %} 4 | 5 | {% block page_name %}One-off Costs{% endblock %} 6 | {% block page_actions %} 7 | 8 | Create New 9 | 10 | {% endblock %} 11 | 12 | {% block explanation_title %}There are no active one-off costs{% endblock %} 13 | 14 | {% block explanation %} 15 |

One-off costs allow you to spread miscellaneous costs between housemates. 16 | These costs can even be spread over several billing cycles if you wish. 17 | These will feature on each housemates' bill.

18 | 19 |

Examples: new TV, house trip, getting a plumber

20 | 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /swiftwind/costs/templates/costs/create_one_off.html: -------------------------------------------------------------------------------- 1 | {% extends 'swiftwind/base.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block page_name %}Create a One-off Cost{% endblock %} 5 | {% block page_description %}{% endblock %} 6 | 7 | 8 | {% block content %} 9 |

10 | A one-off cost will be billed to all housemates. You can choose to spread the cost over several 11 | months if you wish. 12 |

13 | 14 |
15 | {% csrf_token %} 16 | {% bootstrap_form form layout='horizontal' %} 17 | {% for f in formset %} 18 | {{ f }} 19 | {% endfor %} 20 | 21 |
22 | 23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /swiftwind/utilities/testing.py: -------------------------------------------------------------------------------- 1 | from hordak.models.core import Account 2 | from hordak.tests.utils import DataProvider as HordakDataProvider 3 | from swiftwind.housemates.models import Housemate 4 | 5 | 6 | class DataProvider(HordakDataProvider): 7 | 8 | def housemate(self, user=None, account=None, user_kwargs=None, account_kwargs=None): 9 | try: 10 | housemate_income = Account.objects.get(name='Housemate Income') 11 | except Account.DoesNotExist: 12 | housemate_income = None 13 | 14 | return Housemate.objects.create( 15 | user=user or self.user(**(user_kwargs or {})), 16 | account=account or self.account( 17 | type=Account.TYPES.income, 18 | parent=housemate_income, 19 | **(account_kwargs or {}) 20 | ), 21 | ) 22 | -------------------------------------------------------------------------------- /swiftwind/core/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% extends "adminlte/login.html" %} 2 | {% load static bootstrap3 %} 3 | 4 | {% block logo_text %}{% firstof site.name 'Swiftwind' %}{% endblock %} 5 | 6 | {% block form %} 7 |
8 | {% csrf_token %} 9 | {% bootstrap_form_errors form %} 10 | {% bootstrap_field form.username show_label=False placeholder="Username" addon_before='' %} 11 | {% bootstrap_field form.password show_label=False placeholder="Password" addon_before='' %} 12 |
13 |
14 | 15 |
16 |
17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /swiftwind/housemates/templates/housemates/housemates_required_error.html: -------------------------------------------------------------------------------- 1 | {% extends 'swiftwind/base.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block page_name %}Housemates required{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 |
10 |

You need to create some housemates first

11 | It appears you haven't created any housemates yet. You need to do this first before you can 12 | use this page. 13 |
14 | 17 |
18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /swiftwind/settings/templates/settings/teller.html: -------------------------------------------------------------------------------- 1 | {% extends 'settings/base.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block page_name %}Teller.io data import (beta){% endblock %} 5 | {% block page_description %}{% endblock %} 6 | 7 | {% block settings_form %} 8 |

9 | Swiftwind can load you bank statement data in using the 10 | teller.io service. Once you setup 11 | an account you can enter the details below. 12 |

13 | 14 |

15 | The teller token is the token teller providers you with 16 | once you signup. The account ID is the ID of your bank account 17 | as reported by their API. You will need to access their API in order to retrieve your 18 | account ID. 19 |

20 | 21 |

22 | {{ block.super }} 23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/0011_check_fixed_amount_requires_type_normal.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-10-09 12:25 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('costs', '0010_auto_20161009_1225'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RunSQL( 16 | """ 17 | ALTER TABLE costs_recurringcost ADD CONSTRAINT fixed_amount_requires_type_normal 18 | CHECK ( 19 | ("type" = 'normal' AND fixed_amount IS NOT NULL) 20 | OR 21 | ("type" != 'normal' AND fixed_amount IS NULL) 22 | ) 23 | """, 24 | "ALTER TABLE costs_recurringcost DROP CONSTRAINT fixed_amount_requires_type_normal" 25 | ) 26 | ] 27 | -------------------------------------------------------------------------------- /swiftwind/system_setup/middleware.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.http.response import HttpResponseRedirect 3 | from django.urls import reverse 4 | 5 | from swiftwind.settings.models import Settings 6 | 7 | 8 | class CheckSetupDoneMiddleware(object): 9 | """Send the user to the setup UI if no settings exist""" 10 | 11 | def __init__(self, get_response): 12 | self.get_response = get_response 13 | 14 | def __call__(self, request): 15 | url = request.path_info 16 | ignore = ( 17 | url.startswith('/setup') or 18 | url.startswith(settings.STATIC_URL) or 19 | url.startswith(settings.MEDIA_URL) 20 | ) 21 | 22 | if not ignore: 23 | if not Settings.objects.exists(): 24 | return HttpResponseRedirect(reverse('setup:index')) 25 | 26 | return self.get_response(request) 27 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/0014_allow_initial_billing_cycle_and_disabled.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-12-06 01:42 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('costs', '0013_auto_20171203_1516'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RunSQL( 16 | "ALTER TABLE costs_recurringcost DROP CONSTRAINT check_disabled_xor_initial_billing_cycle", 17 | """ 18 | ALTER TABLE costs_recurringcost ADD CONSTRAINT check_disabled_xor_initial_billing_cycle 19 | CHECK ( 20 | (disabled AND initial_billing_cycle_id IS NULL) 21 | OR 22 | (NOT disabled AND initial_billing_cycle_id IS NOT NULL) 23 | ) 24 | """, 25 | ) 26 | ] 27 | -------------------------------------------------------------------------------- /swiftwind/transactions/templates/hordak/transactions/reconcile.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/transactions/reconcile.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block reconcile_form_content %} 5 | {% bootstrap_field transaction_form.description show_label=False %} 6 | {{ leg_formset.management_form }} 7 | 8 | {% for form in leg_formset %} 9 | 10 | 11 | 12 | 13 | 14 | {% endfor %} 15 |
{% bootstrap_field form.amount show_label=False show_help=False form_group_class='form-group money' %}{% bootstrap_field form.description show_label=False show_help=False %}{% bootstrap_field form.account show_label=False show_help=False %}
16 | 17 | 18 | {% endblock reconcile_form_content %} 19 | -------------------------------------------------------------------------------- /swiftwind/core/static/core/css/common.css: -------------------------------------------------------------------------------- 1 | /*Common*/ 2 | .pos { 3 | color: #5cb85c; 4 | } 5 | .neg { 6 | color: #d9534f; 7 | } 8 | .form-group { 9 | overflow: auto; 10 | } 11 | .panel-heading .form-group { 12 | margin: 0; 13 | } 14 | .note { 15 | color: #666; 16 | font-style: italic; 17 | } 18 | 19 | /*Layout*/ 20 | .content-header h1 { 21 | display: inline-block; 22 | } 23 | .content-header .header-actions { 24 | display: inline-block; 25 | margin-left: 20px; 26 | } 27 | /*Recurring Cost UI*/ 28 | .cost .radio { 29 | /*Stop the side of the radio button being chopped off*/ 30 | margin-left: 1px; 31 | } 32 | 33 | /*In common with BattleCat*/ 34 | 35 | form .money input { 36 | width: 50%; 37 | float: left; 38 | } 39 | form .money select { 40 | width: 49%; 41 | float: right; 42 | margin-left: 1%; 43 | } 44 | 45 | .archived-costs, 46 | .disabled-costs { 47 | margin-top: 20px; 48 | } 49 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/0009_check_disabled_xor_billing_cycle.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-10-09 12:14 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('costs', '0008_check_cannot_create_recurred_cost_for_disabled_cost'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RunSQL( 16 | """ 17 | ALTER TABLE costs_recurringcost ADD CONSTRAINT check_disabled_xor_initial_billing_cycle 18 | CHECK ( 19 | (disabled AND initial_billing_cycle_id IS NULL) 20 | OR 21 | (NOT disabled AND initial_billing_cycle_id IS NOT NULL) 22 | ) 23 | """, 24 | "ALTER TABLE costs_recurringcost DROP CONSTRAINT check_disabled_xor_initial_billing_cycle" 25 | ) 26 | ] 27 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/0006_auto_20161009_0019.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-10-09 00:19 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('billing_cycle', '0004_auto_20161001_1707'), 13 | ('costs', '0005_check_recurring_costs_have_splits'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterModelOptions( 18 | name='recurringcostsplit', 19 | options={'base_manager_name': 'objects'}, 20 | ), 21 | migrations.AddField( 22 | model_name='recurringcost', 23 | name='initial_billing_cycle', 24 | field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='billing_cycle.BillingCycle'), 25 | preserve_default=False, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /swiftwind/costs/management/commands/enact_costs.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from swiftwind.billing_cycle.models import BillingCycle 6 | 7 | 8 | class Command(BaseCommand): 9 | help = 'Enact any costs which need to be enacted' 10 | 11 | def add_arguments(self, parser): 12 | # Named (optional) arguments 13 | parser.add_argument( 14 | '--as_of', 15 | action='store', 16 | dest='as_of', 17 | default=None, 18 | help="Enact for billing cycles up until this date, in format YYYY-MM-DD. Defaults to today's date.", 19 | ) 20 | 21 | def handle(self, *args, **options): 22 | if options.get('as_of'): 23 | as_of = date(*map(int, options['as_of'].split('-'))) 24 | else: 25 | as_of = date.today() 26 | for billing_cycle in BillingCycle.objects.filter(start_date__lt=as_of, transactions_created=False): 27 | billing_cycle.enact_all_costs() 28 | -------------------------------------------------------------------------------- /swiftwind/transactions/templates/hordak/transactions/transaction_create.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/transactions/transaction_create.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block content %} 5 |
6 | {% csrf_token %} 7 | {% block form_content %} 8 | {% bootstrap_field form.amount layout='horizontal' form_group_class='form-group money' %} 9 | {% bootstrap_field form.from_account layout='horizontal' %} 10 | {% bootstrap_field form.to_account layout='horizontal' %} 11 | {% bootstrap_field form.date layout='horizontal' %} 12 | {% bootstrap_field form.description layout='horizontal' %} 13 |

14 | 15 | Cancel 16 |

17 | {% endblock %} 18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /swiftwind/bills/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | import model_utils.fields 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Bill', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)), 19 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)), 20 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)), 21 | ('month', models.DateField()), 22 | ('due', models.DateField()), 23 | ], 24 | options={ 25 | 'abstract': False, 26 | }, 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/0013_auto_20171203_1516.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-12-03 15:16 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('costs', '0012_auto_20171129_2247'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='recurringcost', 17 | name='type', 18 | field=models.CharField(choices=[('normal', "We will not have spent this yet. We will estimate a fixed amount per billing cycle. (You should select a 'liabilities' account)."), ('arrears_balance', "We will have already spent this in the previous billing cycle, so bill the account's balance. (You should select an 'expenses' account)"), ('arrears_transactions', "We will have already spent this in the previous cycle, so bill the total amount spent in the previous cycle. (You should select an 'expenses' account)")], default='normal', max_length=20), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /deploy/charts/swiftwind/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $serviceName := include "swiftwind.fullname" . -}} 3 | {{- $servicePort := .Values.service.externalPort -}} 4 | apiVersion: extensions/v1beta1 5 | kind: Ingress 6 | metadata: 7 | name: {{ template "swiftwind.fullname" . }} 8 | labels: 9 | app: {{ template "swiftwind.name" . }} 10 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} 11 | release: {{ .Release.Name }} 12 | heritage: {{ .Release.Service }} 13 | annotations: 14 | {{- range $key, $value := .Values.ingress.annotations }} 15 | {{ $key }}: {{ $value | quote }} 16 | {{- end }} 17 | spec: 18 | rules: 19 | {{- range $host := .Values.ingress.hosts }} 20 | - host: {{ $host }} 21 | http: 22 | paths: 23 | - path: / 24 | backend: 25 | serviceName: {{ $serviceName }} 26 | servicePort: {{ $servicePort }} 27 | {{- end -}} 28 | {{- if .Values.ingress.tls }} 29 | tls: 30 | {{ toYaml .Values.ingress.tls | indent 4 }} 31 | {{- end -}} 32 | {{- end -}} 33 | -------------------------------------------------------------------------------- /swiftwind/accounts/tasks.py: -------------------------------------------------------------------------------- 1 | from celery import shared_task 2 | from django.db import transaction 3 | 4 | from hordak.data_sources import tellerio 5 | from hordak.models.core import Account 6 | from swiftwind.billing_cycle.models import BillingCycle 7 | from swiftwind.settings.models import Settings 8 | 9 | 10 | @shared_task 11 | @transaction.atomic() 12 | def notify_housemates(): 13 | for billing_cycle in BillingCycle.objects.filter(transactions_created=True, statements_sent=False): 14 | billing_cycle.notify_housemates() 15 | 16 | 17 | @shared_task 18 | @transaction.atomic() 19 | def import_tellerio(): 20 | settings = Settings.objects.get() 21 | 22 | if settings.tellerio_enable: 23 | first_billing_cycle = BillingCycle.objects.first() 24 | tellerio.do_import( 25 | token=settings.tellerio_token, 26 | account_uuid=settings.tellerio_account_id, 27 | bank_account=Account.objects.filter(is_bank_account=True)[0], 28 | since=first_billing_cycle.date_range.lower, 29 | ) 30 | return True 31 | else: 32 | return False 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-alpine 2 | 3 | WORKDIR /swiftwind 4 | 5 | ### Setup postgres ### 6 | 7 | RUN \ 8 | apk add -U \ 9 | ca-certificates \ 10 | postgresql-dev gcc python3-dev musl-dev && \ 11 | update-ca-certificates && \ 12 | rm -rf /var/cache/apk/* 13 | 14 | ### Install Swiftwind dependencies ### 15 | 16 | ADD ["requirements.txt", "manage.py", "setup.py", "VERSION", "fixtures", "/swiftwind/"] 17 | 18 | # Dependencies will change fairly often, so do this 19 | # separately to the above 20 | RUN \ 21 | apk add -U git && \ 22 | pip install -r requirements.txt && \ 23 | apk del --purge git && \ 24 | rm -rf /var/cache/apk/* 25 | 26 | RUN pip install -r requirements.txt 27 | 28 | ### Add Swiftwind code ### 29 | 30 | ADD example_project /swiftwind/example_project 31 | ADD swiftwind /swiftwind/swiftwind 32 | 33 | ### Collect the static files ready to be servced ### 34 | 35 | RUN SECRET_KEY=none ./manage.py collectstatic --no-input 36 | 37 | ### Using gunicorn to serve ### 38 | 39 | CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "example_project.wsgi"] 40 | 41 | -------------------------------------------------------------------------------- /swiftwind/transactions/templates/hordak/transactions/currency_trade.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/transactions/currency_trade.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block form %} 5 |
6 | {% csrf_token %} 7 | {% block form_content %} 8 | 9 | {% bootstrap_field form.source_account layout='horizontal' %} 10 | {% bootstrap_field form.source_amount layout='horizontal' form_group_class='form-group money' %} 11 | {% bootstrap_field form.trading_account layout='horizontal' %} 12 | {% bootstrap_field form.destination_account layout='horizontal' %} 13 | {% bootstrap_field form.destination_amount layout='horizontal' form_group_class='form-group money' %} 14 | {% bootstrap_field form.description layout='horizontal' %} 15 | 16 |

17 | 18 | Cancel 19 |

20 | {% endblock %} 21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /swiftwind/costs/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | url(r'^recurring/$', views.RecurringCostsView.as_view(), name='recurring'), 7 | url(r'^oneoff/$', views.OneOffCostsView.as_view(), name='one_off'), 8 | url(r'^recurring/create/$', views.CreateRecurringCostView.as_view(), name='create_recurring'), 9 | url(r'^oneoff/create/$', views.CreateOneOffCostView.as_view(), name='create_one_off'), 10 | url(r'^recurring/delete/(?P.+)/$', views.DeleteRecurringCostView.as_view(), name='delete_recurring'), 11 | url(r'^oneoff/delete/(?P.+)/$', views.DeleteOneOffCostView.as_view(), name='delete_one_off'), 12 | url(r'^recurring/archive/(?P.+)/$', views.ArchiveRecurringCostView.as_view(), name='archive_recurring'), 13 | url(r'^oneoff/archive/(?P.+)/$', views.ArchiveOneOffCostView.as_view(), name='archive_one_off'), 14 | url(r'^recurring/unarchive/(?P.+)/$', views.UnarchiveRecurringCostView.as_view(), name='unarchive_recurring'), 15 | url(r'^oneoff/unarchive/(?P.+)/$', views.UnarchiveOneOffCostView.as_view(), name='unarchive_one_off'), 16 | ] 17 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Adam Charnock 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /swiftwind/core/templatetags/swiftwind_utilities.py: -------------------------------------------------------------------------------- 1 | import six 2 | from django import template 3 | 4 | register = template.Library() 5 | 6 | 7 | @register.filter 8 | def partition(list_, columns=2): 9 | """ 10 | Break a list into ``columns`` number of columns. 11 | """ 12 | 13 | iter_ = iter(list_) 14 | columns = int(columns) 15 | rows = [] 16 | 17 | while True: 18 | row = [] 19 | for column_number in range(1, columns + 1): 20 | try: 21 | value = six.next(iter_) 22 | except StopIteration: 23 | pass 24 | else: 25 | row.append(value) 26 | 27 | if not row: 28 | return rows 29 | rows.append(row) 30 | 31 | @register.filter 32 | def short_name(name): 33 | bits = (name or '').split(' ') 34 | if len(bits) == 0: 35 | return name 36 | else: 37 | first = bits[0] 38 | last = bits[-1] 39 | if last: 40 | # First + Initial 41 | return ' '.join([first, last[0]]) 42 | else: 43 | # No last name, just give the first name 44 | return first 45 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-09-30 11:45 3 | from __future__ import unicode_literals 4 | 5 | import django.contrib.postgres.fields.ranges 6 | from django.db import migrations, models 7 | import django_smalluuid.models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='BillingCycle', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('uuid', django_smalluuid.models.SmallUUIDField(default=django_smalluuid.models.UUIDDefault(), editable=False, unique=True)), 23 | ('date_range', django.contrib.postgres.fields.ranges.DateRangeField(help_text='The start and end date of this billing cycle. May not overlay with any other billing cycles.')), 24 | ('transactions_created', models.BooleanField(default=False, help_text='Have transactions been created for this billing cycle?')), 25 | ], 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from os.path import exists 4 | from setuptools import setup, find_packages 5 | 6 | setup( 7 | name='swiftwind', 8 | version=open('VERSION').read().strip(), 9 | author='Adam Charnock', 10 | author_email='adam@adamcharnock.com', 11 | packages=find_packages(), 12 | scripts=[], 13 | url='https://github.com/adamcharnock/swiftwind', 14 | license='MIT', 15 | description='User-friendly billing for communal households', 16 | long_description=open('README.rst').read() if exists("README.rst") else "", 17 | include_package_data=True, 18 | install_requires=[ 19 | 'django>=1.8, <2', 20 | 'django-hordak>=1.4.0', 21 | 'django-model-utils>=2.5.0', 22 | 'gunicorn', 23 | 'django-bootstrap3 >=9, <10', 24 | 'dj-database-url', 25 | 'dj-static', 26 | 'psycopg2==2.7.3.2', 27 | 'django-extensions', 28 | 'kombu==4.0.2', 29 | 'celery==4.0.2', 30 | 'django-celery-beat==1.0.1', 31 | 'redis==2.10.5', 32 | 'six', 33 | 'python-dateutil', 34 | 'django-adminlte2>=0.1.5', 35 | 'schedule==0.5.0', 36 | ], 37 | ) 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /swiftwind/settings/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.mixins import LoginRequiredMixin 2 | from django.urls.base import reverse_lazy 3 | from django.views.generic.edit import UpdateView 4 | 5 | from .models import Settings 6 | from . import forms 7 | 8 | 9 | class SettingsUpdateView(LoginRequiredMixin, UpdateView): 10 | 11 | def get_object(self, queryset=None): 12 | return Settings.objects.get() 13 | 14 | 15 | class GeneralSettingsView(SettingsUpdateView): 16 | form_class = forms.GeneralSettingsForm 17 | template_name = 'settings/general.html' 18 | success_url = reverse_lazy('settings:general') 19 | 20 | 21 | class TechnicalSettingsView(SettingsUpdateView): 22 | form_class = forms.TechnicalSettingsForm 23 | template_name = 'settings/technical.html' 24 | success_url = reverse_lazy('settings:technical') 25 | 26 | 27 | class EmailSettingsView(SettingsUpdateView): 28 | form_class = forms.EmailSettingsForm 29 | 30 | template_name = 'settings/email.html' 31 | success_url = reverse_lazy('settings:email') 32 | 33 | 34 | class TellerSettingsView(SettingsUpdateView): 35 | form_class = forms.TellerSettingsForm 36 | template_name = 'settings/teller.html' 37 | success_url = reverse_lazy('settings:teller') 38 | -------------------------------------------------------------------------------- /swiftwind/accounts/templates/hordak/accounts/account_update.html: -------------------------------------------------------------------------------- 1 | {% extends 'hordak/accounts/account_update.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block page_name %}Edit account{% endblock %} 5 | {% block page_description %}Create a new account{% endblock %} 6 | 7 | {% block content %} 8 |
9 | {% csrf_token %} 10 | 11 | {% bootstrap_form form layout='horizontal' %} 12 | 13 |
14 | 15 |
16 |
{{ account.get_type_display }}
17 |
18 |
19 | 20 |
21 | 22 |
23 |
{{ account.currencies|join:', ' }}
24 |
25 |
26 | 27 |

28 | 29 | Cancel 30 |

31 |
32 | 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /example_project/urls.py: -------------------------------------------------------------------------------- 1 | """example_project URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.10/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 15 | """ 16 | from django.conf.urls import url, include 17 | from django.contrib import admin 18 | from django.conf.urls.static import static 19 | from django.conf import settings 20 | 21 | urlpatterns = [ 22 | url(r'^admin/', admin.site.urls), 23 | url(r'^auth/', include('django.contrib.auth.urls')), 24 | url(r'^', include('swiftwind.urls')), 25 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 26 | 27 | if settings.ENABLE_DEBUG_TOOLBAR: 28 | import debug_toolbar 29 | urlpatterns += [ 30 | url(r'^__debug__/', include(debug_toolbar.urls)), 31 | ] 32 | -------------------------------------------------------------------------------- /swiftwind/housemates/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-09-27 23:00 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | import django_smalluuid.models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [ 16 | ('hordak', '0003_check_zero_amount_20160907_0921'), 17 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name='Housemate', 23 | fields=[ 24 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 25 | ('uuid', django_smalluuid.models.SmallUUIDField(default=django_smalluuid.models.UUIDDefault(), editable=False, unique=True)), 26 | ('account', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='housemate', to='hordak.Account')), 27 | ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='houesmate', to=settings.AUTH_USER_MODEL)), 28 | ], 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /swiftwind/costs/templates/costs/delete_oneoff.html: -------------------------------------------------------------------------------- 1 | {% extends 'swiftwind/base.html' %} 2 | 3 | {% block page_name %}Delete One-off Cost{% endblock %} 4 | 5 | {% block content %} 6 |
7 | {% csrf_token %} 8 | {% block form_content %} 9 | {{ form }} 10 |
11 |
12 | 13 |
14 |

Are you sure you wish to delete this cost?

15 | 16 |

17 | This cost has not yet been billed, so it is safe to delete. 18 | However, you cannot undo this action. If you are unsure perhaps 19 | archive the cost instead. 20 |

21 |
22 |
23 |
24 |
25 | Cancel 26 | 27 |
28 | {% endblock %} 29 |
30 | 31 | {% endblock %} 32 | ] 33 | -------------------------------------------------------------------------------- /deploy/charts/swiftwind/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range .Values.ingress.hosts }} 4 | http://{{ . }} 5 | {{- end }} 6 | {{- else if contains "NodePort" .Values.service.type }} 7 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ template "swiftwind.fullname" . }}) 8 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 9 | echo http://$NODE_IP:$NODE_PORT 10 | {{- else if contains "LoadBalancer" .Values.service.type }} 11 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 12 | You can watch the status of by running 'kubectl get svc -w {{ template "swiftwind.fullname" . }}' 13 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ template "swiftwind.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}') 14 | echo http://$SERVICE_IP:{{ .Values.service.externalPort }} 15 | {{- else if contains "ClusterIP" .Values.service.type }} 16 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app={{ template "swiftwind.name" . }},release={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 17 | echo "Visit http://127.0.0.1:8080 to use your application" 18 | kubectl port-forward $POD_NAME 8080:{{ .Values.service.internalPort }} 19 | {{- end }} 20 | -------------------------------------------------------------------------------- /swiftwind/accounts/templates/accounts/reconciliation_required_email.html: -------------------------------------------------------------------------------- 1 | {% extends 'swiftwind/base_email.html' %} 2 | {% load bootstrap3 hordak %} 3 | 4 | {% block title %}Reconciliation required{% endblock %} 5 | 6 | {% block preheader %}You need to login and reconcile transactions.{% endblock %} 7 | 8 | {% block content %} 9 | 10 |

Reconciliation required

11 | 12 |

13 | You need to import your bank data and reconcile transactions before we can send the 14 | household statements for this month. 15 |

16 | 17 | 18 | 19 | 20 | 21 | 26 | 27 | 28 | 29 |
22 | 23 | Reconcile statements now 24 | 25 |
30 | 31 | 32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/0005_check_recurring_costs_have_splits.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-10-08 23:16 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('costs', '0004_check_total_billing_cycles_over_zero'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RunSQL( 16 | """ 17 | CREATE OR REPLACE FUNCTION check_recurring_costs_have_splits() 18 | RETURNS trigger AS 19 | $$ 20 | DECLARE 21 | total_splits INT; 22 | BEGIN 23 | SELECT INTO total_splits COUNT(*) FROM costs_recurringcostsplit WHERE recurring_cost_id = NEW.id; 24 | 25 | IF total_splits = 0 THEN 26 | RAISE EXCEPTION 'Recurring costs must be created with splits.' 27 | USING ERRCODE = 'integrity_constraint_violation'; 28 | END IF; 29 | RETURN NEW; 30 | END; 31 | $$ 32 | LANGUAGE plpgsql; 33 | """, 34 | "DROP FUNCTION check_recurring_costs_have_splits()" 35 | ), 36 | migrations.RunSQL( 37 | """ 38 | CREATE CONSTRAINT TRIGGER check_recurring_costs_have_splits_trigger 39 | AFTER INSERT ON costs_recurringcost 40 | DEFERRABLE INITIALLY DEFERRED 41 | FOR EACH ROW EXECUTE PROCEDURE check_recurring_costs_have_splits() 42 | """, 43 | "DROP TRIGGER check_recurring_costs_have_splits_trigger ON costs_recurringcost" 44 | ) 45 | ] 46 | 47 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/0008_check_cannot_create_recurred_cost_for_disabled_cost.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-10-09 12:09 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('costs', '0007_auto_20161009_0106'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RunSQL( 16 | """ 17 | CREATE OR REPLACE FUNCTION check_cannot_create_recurred_cost_for_disabled_cost() 18 | RETURNS trigger AS 19 | $$ 20 | DECLARE 21 | recurring_cost costs_recurringcost%ROWTYPE; 22 | BEGIN 23 | SELECT INTO recurring_cost * FROM costs_recurringcost WHERE id = NEW.recurring_cost_id; 24 | 25 | IF recurring_cost.disabled THEN 26 | RAISE EXCEPTION 'Cannot create RecurredCost for disabled RecurringCost' 27 | USING ERRCODE = 'integrity_constraint_violation'; 28 | END IF; 29 | RETURN NEW; 30 | END; 31 | $$ 32 | LANGUAGE plpgsql; 33 | """, 34 | "DROP FUNCTION check_cannot_create_recurred_cost_for_disabled_cost()" 35 | ), 36 | migrations.RunSQL( 37 | """ 38 | CREATE CONSTRAINT TRIGGER check_cannot_create_recurred_cost_for_disabled_cost_trigger 39 | AFTER INSERT ON costs_recurredcost 40 | FOR EACH ROW EXECUTE PROCEDURE check_cannot_create_recurred_cost_for_disabled_cost() 41 | """, 42 | "DROP TRIGGER check_cannot_create_recurred_cost_for_disabled_cost_trigger ON costs_recurredcost" 43 | ) 44 | ] 45 | -------------------------------------------------------------------------------- /swiftwind/settings/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.conf import settings as django_settings 3 | from django.db.utils import DatabaseError 4 | 5 | 6 | class SettingsConfig(AppConfig): 7 | name = 'swiftwind.settings' 8 | 9 | def ready(self): 10 | from swiftwind.settings.models import Settings 11 | 12 | try: 13 | db_settings = Settings.objects.get() 14 | except DatabaseError: 15 | # Maybe we are running migrations on the settings table 16 | return 17 | 18 | django_settings.DEFAULT_CURRENCY = db_settings.default_currency or 'EUR' 19 | django_settings.CURRENCIES = db_settings.currencies or 'EUR' 20 | 21 | # TODO: Move all settings into here, stop accessing settings model directly 22 | # TODO: IMPORTANT: Settings will only be reloaded on server restart. We need to revisit how we do this. 23 | # Perhaps get_setting(), which optionally takes a request with the settings object attached 24 | # via some middleware (therefore we only have to fetch it once per request) 25 | if db_settings.smtp_host: 26 | django_settings.DEFAULT_FROM_EMAIL = db_settings.from_email 27 | django_settings.EMAIL_HOST = db_settings.smtp_host 28 | django_settings.EMAIL_HOST_PASSWORD = db_settings.smtp_password 29 | django_settings.EMAIL_HOST_USER = db_settings.smtp_user 30 | django_settings.EMAIL_PORT = db_settings.smtp_port 31 | django_settings.EMAIL_SUBJECT_PREFIX = db_settings.smtp_subject_prefix 32 | django_settings.EMAIL_USE_TLS = db_settings.smtp_use_tls 33 | django_settings.EMAIL_USE_SSL = db_settings.smtp_use_ssl 34 | django_settings.EMAIL_TIMEOUT = 10 35 | -------------------------------------------------------------------------------- /swiftwind/settings/migrations/0004_auto_20171207_0014.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2017-12-07 00:14 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('settings', '0003_auto_20171206_0145'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='settings', 17 | name='smtp_host', 18 | field=models.CharField(blank=True, default='', max_length=100), 19 | ), 20 | migrations.AddField( 21 | model_name='settings', 22 | name='smtp_password', 23 | field=models.CharField(blank=True, default='', max_length=100), 24 | ), 25 | migrations.AddField( 26 | model_name='settings', 27 | name='smtp_port', 28 | field=models.IntegerField(blank=True, default=None, null=True), 29 | ), 30 | migrations.AddField( 31 | model_name='settings', 32 | name='smtp_subject_prefix', 33 | field=models.CharField(blank=True, default='[swiftwind] ', max_length=100), 34 | ), 35 | migrations.AddField( 36 | model_name='settings', 37 | name='smtp_use_ssl', 38 | field=models.BooleanField(default=False), 39 | ), 40 | migrations.AddField( 41 | model_name='settings', 42 | name='smtp_use_tls', 43 | field=models.BooleanField(default=False), 44 | ), 45 | migrations.AddField( 46 | model_name='settings', 47 | name='smtp_user', 48 | field=models.CharField(blank=True, default='', max_length=100), 49 | ), 50 | ] 51 | -------------------------------------------------------------------------------- /swiftwind/system_setup/views.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from django.contrib.auth import authenticate, login 3 | from django.contrib.auth.mixins import LoginRequiredMixin 4 | from django.db import transaction 5 | from django.http.response import HttpResponseRedirect 6 | from django.urls.base import reverse_lazy, reverse 7 | from django.views.generic import FormView 8 | 9 | from swiftwind.settings.models import Settings 10 | from swiftwind.system_setup.forms import SetupForm 11 | 12 | 13 | class SetupView(FormView): 14 | template_name = 'setup/index.html' 15 | form_class = SetupForm 16 | success_url = reverse_lazy('dashboard:dashboard') 17 | 18 | def dispatch(self, request, *args, **kwargs): 19 | if Settings.objects.exists(): 20 | return HttpResponseRedirect(reverse('dashboard:dashboard')) 21 | else: 22 | return super().dispatch(request, *args, **kwargs) 23 | 24 | def get_initial(self): 25 | initial = super(SetupView, self).get_initial() 26 | port = self.request.get_port() 27 | domain = self.request.get_host() 28 | if port != '80' and ':' not in domain: 29 | domain = '{}:{}'.format(domain, port) 30 | 31 | initial.update( 32 | site_domain=domain, 33 | use_https=self.request.is_secure(), 34 | accounting_start_date=date(date.today().year, date.today().month, 1) 35 | ) 36 | return initial 37 | 38 | def form_valid(self, form): 39 | with transaction.atomic(): 40 | form.save() 41 | 42 | # Now login as the newly created user 43 | user = authenticate( 44 | username=form.cleaned_data['username'], 45 | password=form.cleaned_data['password1'] 46 | ) 47 | login(self.request, user) 48 | return super().form_valid(form) 49 | -------------------------------------------------------------------------------- /swiftwind/core/tests.py: -------------------------------------------------------------------------------- 1 | from django.test.testcases import TestCase 2 | 3 | from swiftwind.core.exceptions import CannotCreateMultipleSettingsInstances 4 | from swiftwind.core.management.commands.swiftwind_create_accounts import Command as CreateChartOfAccountsCommand 5 | from hordak.models.core import Account 6 | from swiftwind.settings.models import Settings 7 | from swiftwind.utilities.testing import DataProvider 8 | 9 | 10 | class CreateChartOfAccountsCommandTestCase(DataProvider, TestCase): 11 | 12 | def test_create(self): 13 | CreateChartOfAccountsCommand().handle() 14 | self.assertTrue(Account.objects.count() > 10) 15 | 16 | def test_create_currency(self): 17 | CreateChartOfAccountsCommand().handle(currency='GBP') 18 | self.assertEqual(Account.objects.get(name='Income').currencies, ['GBP']) 19 | 20 | def test_preserve(self): 21 | CreateChartOfAccountsCommand().handle(preserve=True) 22 | self.assertTrue(Account.objects.count() > 10) 23 | 24 | def test_preserve_with_existing_account(self): 25 | self.account() 26 | CreateChartOfAccountsCommand().handle(preserve=True) 27 | self.assertTrue(Account.objects.count() > 10) 28 | 29 | 30 | class SettingsTestCase(TestCase): 31 | 32 | def test_get_creates(self): 33 | settings = Settings.objects.get() 34 | self.assertEqual(Settings.objects.count(), 1) 35 | self.assertEqual(settings.default_currency, 'EUR') 36 | 37 | def test_get(self): 38 | # First create 39 | Settings.objects.get() 40 | self.assertEqual(Settings.objects.count(), 1) 41 | # Now get 42 | settings = Settings.objects.get() 43 | self.assertEqual(settings.default_currency, 'EUR') 44 | 45 | def test_get_create_error(self): 46 | # Cannot create a second instance 47 | Settings.objects.get() 48 | with self.assertRaises(CannotCreateMultipleSettingsInstances): 49 | Settings.objects.create() 50 | -------------------------------------------------------------------------------- /swiftwind/housemates/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.contrib.auth.mixins import LoginRequiredMixin 3 | from django.template.response import TemplateResponse 4 | from django.urls import reverse 5 | from django.utils.decorators import method_decorator 6 | from django.views import View 7 | from django.views.generic import CreateView 8 | from django.views.generic import UpdateView 9 | from django.views.generic.list import ListView 10 | 11 | from swiftwind.housemates.forms import HousemateUpdateForm 12 | from .forms import HousemateCreateForm 13 | from .models import Housemate 14 | 15 | 16 | class HousemateListView(LoginRequiredMixin, ListView): 17 | template_name = 'housemates/list.html' 18 | context_object_name = 'housemates' 19 | queryset = Housemate.objects.all()\ 20 | .order_by('user__is_active', 'user__first_name', 'user__last_name')\ 21 | .select_related('user', 'account') 22 | 23 | 24 | class HousemateCreateView(LoginRequiredMixin, CreateView): 25 | template_name = 'housemates/create.html' 26 | form_class = HousemateCreateForm 27 | 28 | def get_success_url(self): 29 | return reverse('housemates:list') 30 | 31 | 32 | class HousemateUpdateView(LoginRequiredMixin, UpdateView): 33 | template_name = 'housemates/update.html' 34 | form_class = HousemateUpdateForm 35 | model = Housemate 36 | slug_url_kwarg = 'uuid' 37 | slug_field = 'uuid' 38 | context_object_name = 'housemate' 39 | 40 | def get_success_url(self): 41 | return reverse('housemates:list') 42 | 43 | 44 | class HousematesRequiredMixin(object): 45 | """Require that some housemates have been created""" 46 | 47 | def dispatch(self, request, *args, **kwargs): 48 | if Housemate.objects.exists(): 49 | return super().dispatch(request, *args, **kwargs) 50 | else: 51 | return TemplateResponse( 52 | request=request, 53 | template='housemates/housemates_required_error.html' 54 | ) 55 | -------------------------------------------------------------------------------- /swiftwind/dashboard/views.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from django.contrib.auth.mixins import LoginRequiredMixin 4 | from django.db.models import Q 5 | from django.views.generic import TemplateView 6 | from django.conf import settings 7 | from hordak.models import Account 8 | from hordak.utilities.currency import Balance 9 | from swiftwind.billing_cycle.models import BillingCycle 10 | 11 | 12 | class DashboardView(LoginRequiredMixin, TemplateView): 13 | template_name = 'dashboard/dashboard.html' 14 | 15 | def get_balance_context(self): 16 | """Get the high level balances""" 17 | bank_account = Account.objects.get(name='Bank') 18 | 19 | return dict( 20 | bank=bank_account, 21 | retained_earnings_accounts=Account.objects.filter(parent__name='Retained Earnings'), 22 | ) 23 | 24 | def get_accounts_context(self): 25 | """Get the accounts we may want to display""" 26 | income_parent = Account.objects.get(name='Income') 27 | housemate_parent = Account.objects.get(name='Housemate Income') 28 | expense_parent = Account.objects.get(name='Expenses') 29 | current_liabilities_parent = Account.objects.get(name='Current Liabilities') 30 | long_term_liabilities_parent = Account.objects.get(name='Long Term Liabilities') 31 | 32 | return dict( 33 | housemate_accounts=Account.objects.filter(parent=housemate_parent), 34 | expense_accounts=expense_parent.get_descendants(), 35 | current_liability_accounts=Account.objects.filter(parent=current_liabilities_parent), 36 | long_term_liability_accounts=Account.objects.filter(parent=long_term_liabilities_parent), 37 | other_income_accounts=Account.objects.filter(~Q(pk=housemate_parent.pk), parent=income_parent) 38 | ) 39 | 40 | def get_context_data(self, **kwargs): 41 | context = super(DashboardView, self).get_context_data() 42 | 43 | context.update(**self.get_balance_context()) 44 | context.update(**self.get_accounts_context()) 45 | 46 | return context 47 | -------------------------------------------------------------------------------- /deploy/charts/swiftwind/templates/scheduler.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Deployment 3 | metadata: 4 | name: {{ template "swiftwind.fullname" . }}-scheduler 5 | labels: 6 | app: {{ template "swiftwind.name" . }} 7 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} 8 | release: {{ .Release.Name }} 9 | heritage: {{ .Release.Service }} 10 | spec: 11 | replicas: 1 12 | template: 13 | metadata: 14 | labels: 15 | app: {{ template "swiftwind.name" . }} 16 | release: {{ .Release.Name }} 17 | spec: 18 | containers: 19 | - name: scheduler 20 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 21 | imagePullPolicy: {{ .Values.image.pullPolicy }} 22 | args: ['./manage.py', 'run_scheduler'] 23 | env: 24 | {{- if .Values.postgresql.enabled }} 25 | - name: POSTGRES_PASSWORD 26 | valueFrom: 27 | secretKeyRef: 28 | # We shouldn't hard code 'postgresql' here, but the following: 29 | # name: {{ template "postgresql.fullname" . }} 30 | # ...kept printing "bills-swiftwind" rather than "bills-postgresql" 31 | name: {{ printf "%s-postgresql" .Release.Name | trunc 63 | trimSuffix "-" }} 32 | key: postgres-password 33 | - name: POSTGRES_USER 34 | value: {{ default "postgres" .Values.postgresql.postgresUser | quote }} 35 | - name: POSTGRES_DB 36 | value: {{ default "" .Values.postgresql.postgresDatabase | quote }} 37 | - name: POSTGRES_HOST 38 | # See note above 39 | value: {{ printf "%s-postgresql" .Release.Name | trunc 63 | trimSuffix "-" }} 40 | - name: POSTGRES_PORT 41 | value: {{ .Values.postgresql.service.port | quote }} 42 | {{- else }} 43 | - name: DATABASE_URL 44 | value: {{ .Values.database_url | quote }} 45 | {{- end }} 46 | resources: 47 | {{ toYaml .Values.resources | indent 12 }} 48 | {{- if .Values.nodeSelector }} 49 | nodeSelector: 50 | {{ toYaml .Values.nodeSelector | indent 8 }} 51 | {{- end }} 52 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/migrations/0003_check_adjacent.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-09-30 12:56 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('billing_cycle', '0002_check_non_overlapping'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RunSQL( 16 | """ 17 | CREATE OR REPLACE FUNCTION check_billing_cycle_adjacent() 18 | RETURNS trigger AS 19 | $$ 20 | DECLARE 21 | previous billing_cycle_billingcycle%ROWTYPE; 22 | row billing_cycle_billingcycle%ROWTYPE; 23 | is_adjacent BOOLEAN; 24 | BEGIN 25 | previous := NULL; 26 | FOR row IN SELECT * FROM billing_cycle_billingcycle ORDER BY date_range LOOP 27 | IF previous IS NULL THEN 28 | previous := row; 29 | CONTINUE; 30 | END IF; 31 | 32 | is_adjacent := previous.date_range -|- row.date_range; 33 | IF NOT is_adjacent THEN 34 | RAISE EXCEPTION 'Billing cycles % % and % % are not adjacent. All cycles must be adjacent.', 35 | previous.uuid, previous.date_range, row.uuid, row.date_range 36 | USING ERRCODE = 'integrity_constraint_violation'; 37 | END IF; 38 | 39 | previous := row; 40 | END LOOP; 41 | 42 | RETURN NEW; 43 | 44 | END; 45 | $$ 46 | LANGUAGE plpgsql 47 | """, 48 | "DROP FUNCTION check_billing_cycle_adjacent()" 49 | ), 50 | migrations.RunSQL( 51 | """ 52 | CREATE CONSTRAINT TRIGGER check_billing_cycle_adjacent_trigger 53 | AFTER INSERT OR UPDATE OR DELETE ON billing_cycle_billingcycle 54 | DEFERRABLE INITIALLY DEFERRED 55 | FOR EACH ROW EXECUTE PROCEDURE check_billing_cycle_adjacent() 56 | """, 57 | "DROP TRIGGER check_billing_cycle_adjacent_trigger ON billing_cycle_billingcycle" 58 | ) 59 | ] 60 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/views.py: -------------------------------------------------------------------------------- 1 | from datetime import date, timedelta 2 | 3 | from django.contrib.auth.mixins import LoginRequiredMixin 4 | from django.http.response import HttpResponseRedirect 5 | from django.shortcuts import get_object_or_404 6 | from django.urls import reverse 7 | from django.views import View 8 | from django.views.generic import ListView 9 | 10 | from swiftwind.billing_cycle.models import BillingCycle 11 | 12 | 13 | class BillingCycleListView(LoginRequiredMixin, ListView): 14 | template_name = 'billing_cycle/list.html' 15 | context_object_name = 'billing_cycles' 16 | 17 | def get_queryset(self): 18 | return BillingCycle.objects.filter( 19 | start_date__lte=date.today() 20 | ).order_by('-date_range') 21 | 22 | 23 | class CreateTransactionsView(LoginRequiredMixin, View): 24 | 25 | def post(self, request, uuid): 26 | billing_cycle = get_object_or_404(BillingCycle, uuid=uuid) 27 | if billing_cycle.can_create_transactions(): 28 | billing_cycle.enact_all_costs() 29 | return HttpResponseRedirect(reverse('billing_cycles:list')) 30 | 31 | 32 | class RecreateTransactionsView(LoginRequiredMixin, View): 33 | """For those times when you realise you're costs were not setup correctly""" 34 | 35 | def post(self, request, uuid): 36 | billing_cycle = get_object_or_404(BillingCycle, uuid=uuid) 37 | billing_cycle.reenact_all_costs() 38 | return HttpResponseRedirect(reverse('billing_cycles:list')) 39 | 40 | 41 | class DeleteTransactionsView(LoginRequiredMixin, View): 42 | """For those times when you need to delete the transactions and faff about some more""" 43 | 44 | def post(self, request, uuid): 45 | billing_cycle = get_object_or_404(BillingCycle, uuid=uuid) 46 | billing_cycle.unenact_all_costs() 47 | return HttpResponseRedirect(reverse('billing_cycles:list')) 48 | 49 | 50 | class SendNotificationsView(LoginRequiredMixin, View): 51 | 52 | def post(self, request, uuid): 53 | billing_cycle = get_object_or_404(BillingCycle, uuid=uuid) 54 | if billing_cycle.can_send_statements(): 55 | billing_cycle.send_statements(force=True) 56 | return HttpResponseRedirect(reverse('billing_cycles:list')) 57 | 58 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | SwiftWind 2 | ========= 3 | 4 | This readme has been autogenerated 5 | 6 | .. image:: https://img.shields.io/pypi/v/swiftwind.svg 7 | :target: https://badge.fury.io/py/swiftwind 8 | 9 | .. image:: https://img.shields.io/pypi/dm/swiftwind.svg 10 | :target: https://pypi.python.org/pypi/swiftwind 11 | 12 | .. image:: https://img.shields.io/github/license/adamcharnock/swiftwind.svg 13 | :target: https://pypi.python.org/pypi/swiftwind/ 14 | 15 | .. image:: https://travis-ci.org/adamcharnock/swiftwind.svg?branch=master 16 | :target: https://travis-ci.org/adamcharnock/swiftwind/ 17 | 18 | .. image:: https://coveralls.io/repos/github/adamcharnock/swiftwind/badge.svg?branch=master 19 | :target: https://coveralls.io/github/adamcharnock/swiftwind?branch=master 20 | 21 | Requirements 22 | ------------ 23 | 24 | * Django >= 1.8 25 | * Postgres >= 9.5 26 | * Python >=3.4 27 | 28 | Installation 29 | ------------ 30 | 31 | You can deploy directly to heroku using the swiftwind-heroku_ repository: 32 | 33 | .. image:: https://www.herokucdn.com/deploy/button.svg 34 | :target: https://heroku.com/deploy?template=https://github.com/adamcharnock/swiftwind-heroku 35 | 36 | You can also deploy Swiftwind within you own Django project. Good examples 37 | of this are the `example_project`_ within this directory, and the 38 | separate `swiftwind-heroku`_ project. 39 | 40 | Usage 41 | ----- 42 | 43 | Once installed you will need to run the following ``manage.py`` commands:: 44 | 45 | ./manage.py migrate 46 | ./manage.py createsuperuser 47 | ./manage.py swiftwind_create_accounts 48 | 49 | Docker compose 50 | -------------- 51 | 52 | Swiftwind can be run using docker-compose. This will also provide the Postgres database server for you:: 53 | 54 | docker-compose run app ./manage.py migrate 55 | docker-compose up 56 | 57 | 58 | Credits 59 | ------- 60 | 61 | Developed by `Adam Charnock`_. I'm a freelance developer, so do get in touch if you have a project. 62 | 63 | swiftwind is packaged using seed_. 64 | 65 | .. _seed: https://github.com/adamcharnock/seed/ 66 | .. _swiftwind-heroku: https://github.com/adamcharnock/swiftwind-heroku 67 | .. _example_project: https://github.com/adamcharnock/swiftwind/tree/master/example_project 68 | .. _Adam Charnock: https://adamcharnock.com 69 | -------------------------------------------------------------------------------- /swiftwind/housemates/templates/housemates/list.html: -------------------------------------------------------------------------------- 1 | {% extends 'swiftwind/base.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block page_name %}Housemates{% endblock %} 5 | {% block page_description %}{% endblock %} 6 | 7 | {% block page_actions %} 8 | Create new housemate 9 | {% endblock %} 10 | 11 | {% block content %} 12 |
13 | {% csrf_token %} 14 | 15 |
16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {% for housemate in housemates %} 29 | 30 | 31 | 34 | 41 | 42 | 45 | 46 | {% endfor %} 47 | 48 |
EmailActiveLast login
{% firstof housemate.user.get_full_name housemate.user.username '-' %} 32 | {{ housemate.user.email }} 33 | 35 | {% if housemate.user.is_active %} 36 | 37 | {% else %} 38 | 39 | {% endif %} 40 | {% firstof housemate.user.last_login 'Never' %} 43 | Edit 44 |
49 | 50 |
51 |
52 |
53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '3.4' 4 | - '3.5' 5 | - '3.6' 6 | services: 7 | - postgresql 8 | env: 9 | - DJANGO_VERSION=1.10.4 HORDAK_INSTALL_ARGS=django-hordak 10 | - DJANGO_VERSION=1.9.12 HORDAK_INSTALL_ARGS=django-hordak 11 | - DJANGO_VERSION=1.8.17 HORDAK_INSTALL_ARGS=django-hordak 12 | - DJANGO_VERSION=1.10.4 HORDAK_INSTALL_ARGS=django-hordak 13 | - DJANGO_VERSION=1.10.4 HORDAK_INSTALL_ARGS=git+git://github.com/adamcharnock/django-hordak.git@master#egg=django-hordak 14 | install: 15 | - python setup.py develop 16 | - pip install . 17 | - pip uninstall -y django-hordak 18 | - pip install pytest coveralls freezegun $HORDAK_INSTALL_ARGS 19 | - pip install -q django==$DJANGO_VERSION 20 | script: 21 | - env 22 | - env PYTHONPATH=`pwd` coverage run --source=swiftwind ./manage.py test 23 | addons: 24 | postgresql: "9.5" 25 | after_success: coveralls 26 | notifications: 27 | pushover: 28 | on_success: always 29 | on_failure: always 30 | api_key: 31 | secure: lepE3WdovnCx3IUwIK4/eG3FsGd9/mY8cZsF7lzPbCbjXtjRVFEeHVORi+YJd96kWKr7HbNYFLHH9ibNHbi9tejlT34aW8Xr3H1sOxZ5XEQCKp29xbgd9pPBDgLANvMGJvqmvV1EyTJqAnl2rfI3n24jteXIPBDFCISemshJiJnc0Ou7T/MXVuBbkH0URAKcSPHMilgQmON4dtnaN/f+nHzaQCFI3+dQpiJ/ZBA+yWQpioJbkhXvSUWwDLhpxmU2/ZMf3Y4cyZf9PO2CGuRwBXmdtEV2j5QnlIJryMMdE0nXTogDhSm0ZSNSutuBD8Ruht3Wo7qN3SXpMR38tGdSlNvmXX/U2vZbUzekt7DjrB1lZWun3pKIOGiDPuawSKi8up1mSxgWGef+W62OzveBXhZ0XSOQlWvvMbSdBRpzd2BcNdc170UUClAEKYGRkgGptNTtMWRBBDmsWYhzf2KNrcPE+nxiGm5w+lpqfJBObK7GMI1n4x5KAkWFYWnGoWedr78Z2sC+4FoAq++ilvlC3wuTDZhlCRosSwODTR1pxyvqHCTSHtvnCpIIU9RLTYNONJTgUixoFqwf4NDS4zXHNayAqNvlgDe5m+cjDwGPM3SDXHs4k4TP9oAi+FzRhAjOuzE9/HwXVqqRMMp4JXrk0kPQG2tpERMEuiQuFU0l02I= 32 | users: 33 | secure: HK67fmH/RMVOf6HBbs3AYITQyT6ehRLQoqKDfO0a16W2P1a+Je8U5ZlwHTvv00JjH6IJnhEcen44sglLUdFaTb4I8jxG1ikZou+W1bbYa3El51ar7P8a0EcRt3UfVfDoxNSF+zb75VzIgEy51Ct/f0xJEiv/grdyM4w6Z+T8oYKkVSa6xRaKMRl6qCFO6GTybqXEfIYe7XKsmR8ofszcKKUXInTVWCzHZlAZvALDaCcKDBeslbmr1hoZyb+jU5qKQ8LQTYNB50BM0G8d/Wro3qsErg9rsomdgaeF8i6XuVnROFhsX3BHWcOXy2gPq/4xrzmLUQct0O/K88G2zUttltTIqYaB+6Uu+fCpXuLdInT/jhum6x5/dvjbKIUkLEG3NR44xhY5YHWVTZAFS/QoUtsmgaHCLWU4GQX6rcdvrz9Dw6LLS/6b+3oiSmUMWv1QZqz3ROCxCYZOVu/y57l31ulAipL9lZbWMJZ2v3lsp9pDy0UsVQJi2YC7OHmtTjXdOrOR8i3+6Wq3rlg1CP87Fyvcs2UvWq9hRyqCa6jrvqL6w1Jn8ZGmAQg0WrBwst4CESyjw4p32jT5dG7v2nI7irN/j7ueGya6ZUljBv5C/viu+PlqDGsgrKgdoIEmGgGcl6Om4MC/gK5pjS9EqW4siAW4jxUxvlQRro9LZSAMDyc= 34 | -------------------------------------------------------------------------------- /swiftwind/housemates/templates/housemates/create.html: -------------------------------------------------------------------------------- 1 | {% extends 'swiftwind/base.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block page_name %}Create Housemate{% endblock %} 5 | {% block page_description %}Create a new housemate with their own user and account{% endblock %} 6 | 7 | {% block content %} 8 |
9 | {% csrf_token %} 10 | 11 |
12 |
13 |

New housemate login

14 |
15 |
16 |

17 | Each housemate has a swiftwind login, allowing them to access their account history. 18 | You can select an existing login or (more likely), create a new login for the new housemate. 19 |

20 | 21 |
22 |
23 |

Select an existing login, or...

24 | {% bootstrap_field form.existing_user show_label=False %} 25 |
26 |
27 |

...create a new login

28 | {% bootstrap_field form.new_username layout='horizontal' %} 29 | {% bootstrap_field form.new_email layout='horizontal' %} 30 | {% bootstrap_field form.new_first_name layout='horizontal' %} 31 | {% bootstrap_field form.new_last_name layout='horizontal' %} 32 |
33 |
34 |
35 |
36 | 37 |
38 |
39 |

New housemate financial account

40 |
41 |
42 |

43 | Each housemate has their own financial account. By default we will create a 44 | financial account for the new housemate automatically, so you can probably ignore this 45 | unless you want to do something fancy. 46 |

47 | 48 |
49 |
50 |

Select an account (optional)

51 | {% bootstrap_field form.account %} 52 |
53 |
54 |
55 |
56 | 57 | 58 |
59 | {% endblock %} 60 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/0002_auto_20161008_1841.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-10-08 18:41 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import django.utils.timezone 8 | import django_smalluuid.models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | ('hordak', '0003_check_zero_amount_20160907_0921'), 15 | ('billing_cycle', '0004_auto_20161001_1707'), 16 | ('costs', '0001_initial'), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='RecurredCost', 22 | fields=[ 23 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 24 | ('uuid', django_smalluuid.models.SmallUUIDField(default=django_smalluuid.models.UUIDDefault(), editable=False, unique=True)), 25 | ('timestamp', models.DateTimeField(default=django.utils.timezone.now, editable=False)), 26 | ('billing_cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recurring_costs', to='billing_cycle.BillingCycle')), 27 | ], 28 | ), 29 | migrations.RemoveField( 30 | model_name='recurringcost', 31 | name='is_active', 32 | ), 33 | migrations.AddField( 34 | model_name='recurringcost', 35 | name='disabled', 36 | field=models.BooleanField(default=False), 37 | ), 38 | migrations.AlterField( 39 | model_name='recurringcost', 40 | name='type', 41 | field=models.CharField(choices=[('normal', 'We will not have spent this yet. We will estimate a fixed amount per billing cycle.'), ('arrears_balance', "We will have already spent this in the previous billing cycle, so bill the account's balance."), ('arrears_transactions', 'We will have already spent this in the previous cycle, so bill the total amount spent in the previous cycle.')], default='normal', max_length=20), 42 | ), 43 | migrations.AddField( 44 | model_name='recurredcost', 45 | name='recurring_cost', 46 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recurrences', to='costs.RecurringCost'), 47 | ), 48 | migrations.AddField( 49 | model_name='recurredcost', 50 | name='transaction', 51 | field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='recurred_cost', to='hordak.Transaction'), 52 | ), 53 | migrations.AddField( 54 | model_name='recurringcost', 55 | name='transactions', 56 | field=models.ManyToManyField(through='costs.RecurredCost', to='hordak.Transaction'), 57 | ), 58 | migrations.AlterUniqueTogether( 59 | name='recurredcost', 60 | unique_together=set([('recurring_cost', 'billing_cycle')]), 61 | ), 62 | ] 63 | -------------------------------------------------------------------------------- /swiftwind/urls.py: -------------------------------------------------------------------------------- 1 | """example_project URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.10/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 15 | """ 16 | from django.conf.urls import url, include 17 | 18 | from hordak import views as hordak_views 19 | 20 | 21 | # All the following will appear in the 'hordak' namespace (i.e. 'hordak:accounts_transactions') 22 | hordak_urls = [ 23 | url(r'^extra/transactions/create/$', hordak_views.TransactionCreateView.as_view(), name='transactions_create'), 24 | url(r'^extra/transactions/currency/$', hordak_views.CurrencyTradeView.as_view(), name='currency_trade'), 25 | url(r'^extra/transactions/reconcile/$', hordak_views.TransactionsReconcileView.as_view(), name='transactions_reconcile'), 26 | url(r'^extra/transactions/(?P.+)/delete/$', hordak_views.TransactionDeleteView.as_view(), name='transactions_delete'), 27 | url(r'^statement-line/(?P.+)/unreconcile/$', hordak_views.UnreconcileView.as_view(), name='transactions_unreconcile'), 28 | url(r'^extra/accounts/$', hordak_views.AccountListView.as_view(), name='accounts_list'), 29 | url(r'^extra/accounts/create/$', hordak_views.AccountCreateView.as_view(), name='accounts_create'), 30 | url(r'^extra/accounts/update/(?P.+)/$', hordak_views.AccountUpdateView.as_view(), name='accounts_update'), 31 | url(r'^extra/accounts/(?P.+)/$', hordak_views.AccountTransactionsView.as_view(), name='accounts_transactions'), 32 | 33 | url(r'^import/$', hordak_views.CreateImportView.as_view(), name='import_create'), 34 | url(r'^import/(?P.*)/setup/$', hordak_views.SetupImportView.as_view(), name='import_setup'), 35 | url(r'^import/(?P.*)/dry-run/$', hordak_views.DryRunImportView.as_view(), name='import_dry_run'), 36 | url(r'^import/(?P.*)/run/$', hordak_views.ExecuteImportView.as_view(), name='import_execute'), 37 | ] 38 | 39 | urlpatterns = [ 40 | url(r'^housemates/', include('swiftwind.housemates.urls', namespace='housemates')), 41 | url(r'^accounts/', include('swiftwind.accounts.urls', namespace='accounts')), 42 | url(r'^costs/', include('swiftwind.costs.urls', namespace='costs')), 43 | url(r'^billing-cycles/', include('swiftwind.billing_cycle.urls', namespace='billing_cycles')), 44 | url(r'^setup/', include('swiftwind.system_setup.urls', namespace='setup')), 45 | url(r'^settings/', include('swiftwind.settings.urls', namespace='settings')), 46 | url(r'^', include('swiftwind.dashboard.urls', namespace='dashboard')), 47 | 48 | url(r'^', include(hordak_urls, namespace='hordak', app_name='hordak')), 49 | ] 50 | 51 | -------------------------------------------------------------------------------- /swiftwind/costs/plan.md: -------------------------------------------------------------------------------- 1 | Recurring Costs Plan 2 | ==================== 3 | 4 | 5 | Questions (& Answers) 6 | --------------------- 7 | 8 | 1. What is in invoice? 9 | * An invoice tells someone how much they owe, what it was for, and how to pay. It is /informational/. 10 | * Secondarily, an invoice can provide information to aid in reconciling (i.e. how the income should 11 | be split between accounts) 12 | 2. Do we: 13 | * Create the invoice, which then immediately creates the necessary transactions? 14 | * Create the transactions, then create the invoice based on them? 15 | * The latter, because: Creating invoices based on transactions will also scoop up any other activity in that account 16 | which was not part of the regular billing cycle 17 | 3. How do we auto generate transactions & invoices? 18 | * Requirements: 19 | * Changes to the billing cycle should not affect existing data 20 | * Should handle a failure to generate earlier transactions gracefully (i.e. bring the system up-to-date) 21 | * Generation should be reenterant (running it twice should not mess anything up) 22 | * Solution: 23 | * Create a BillingCycle model, with one instance for each billing cycle. 24 | * Has: daterange (db constraint. must not overlap), transactions_created (bool). Link to the created transactions? 25 | * Generate 1 or 2 years of BillingCycle models ahead of time 26 | * At a change in billing cycle we delete & recreate all BillingCycle models which start in the future 27 | * Cannot create transactions for BillingCycles which start in the future (db constraint) 28 | * Transaction & invoice generation runs for all billing cycles 29 | which a) have not been invoiced, AND b) start in the past. 30 | * Create line items based on the transactions... 31 | * ...in the current billing cycle for items billed normally 32 | * ...in the current billing cycle for items billed in arrears 33 | * How do we know which transactions to auto-create? 34 | * Some line items will be the total of all transactions for an account in the previous cycle 35 | * Some line items will be estimated based on expected values 36 | 4. How do we store the details of line-items to auto-create? 37 | * See Recurring Costs wireframe. We need a RecurringCosts model 38 | * Amount can be calculated based on: fixed amount, sum of transactions in previous cycle, balance at end of previous cycle 39 | * To put it another way, the options are: 40 | * We will have already spent this in the previous cycle, so bill the account's balance 41 | * We will have already spent this in the previous cycle, so bill the total amount spent in the previous cycle 42 | * We will not have spent this yet, but we expect to spend [___] per billing cycle 43 | 44 | 1. There is a concept of a billing cycle (i.e. weekly, monthly) 45 | 2. Housemates have a bill generated for that billing cycle 46 | 3. Up-to-date reconciliation can be required in order for bills to be generated 47 | 4. We want to auto-generate invoices. 48 | * The line-items should come from outbound transactions in the current billing cycle 49 | * We should indicate their account balance before and after, and highlight the latter as the owed amount 50 | 51 | 52 | -------------------------------------------------------------------------------- /swiftwind/accounts/templates/accounts/statement_email.html: -------------------------------------------------------------------------------- 1 | {% extends 'swiftwind/base_email.html' %} 2 | {% load bootstrap3 hordak %} 3 | 4 | {% block title %}Statement for {{ housemate.user }}{% endblock %} 5 | 6 | {% block preheader %} 7 | {% with balance=housemate.account.balance %} 8 | {% if balance < 0 %} 9 |

You currently owe {{ balance|inv }}

10 | {% elif balance > 0 %} 11 |

The house currently owes you {{ balance }}

12 | {% else %} 13 |

Your account balance is {{ balance }}

14 | {% endif %} 15 | {% endwith %} 16 | {% endblock %} 17 | 18 | {% block content %} 19 | 20 |

Your account balance

21 | 22 | {% with balance=housemate.account.balance %} 23 | {% if balance < 0 %} 24 |

You currently owe {{ balance|inv }}

25 | {% elif balance > 0 %} 26 |

The house currently owes you {{ balance }}

27 | {% else %} 28 |

Your account balance is {{ balance }}

29 | {% endif %} 30 | {% endwith %} 31 | 32 |
33 | 34 |

Costs from {{ start_date }} to {{ end_date }}

35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |
Recurring costs{{ recurring_total|inv }}
One-off costs{{ one_off_total|inv }}
Other transactions{{ other_total|inv }}
Total for this period{{ total|inv }}
56 | 57 | 58 | 59 | 60 | 61 | 66 | 67 | 68 | 69 |
62 | 63 | View full statement 64 | 65 |
70 | 71 | {% if payment_information %} 72 |
73 |

{{ payment_information|linebreaksbr }}

74 | {% endif %} 75 | 76 | 77 | {% endblock %} 78 | -------------------------------------------------------------------------------- /swiftwind/settings/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.postgres.fields import ArrayField 2 | from django.db import models 3 | 4 | # Create your models here. 5 | from djmoney.settings import CURRENCY_CHOICES 6 | 7 | from swiftwind.core.exceptions import CannotCreateMultipleSettingsInstances 8 | 9 | 10 | class SettingsManager(models.Manager): 11 | 12 | def get(self): 13 | # TODO: Pull from cache 14 | try: 15 | return super(SettingsManager, self).get() 16 | except Settings.DoesNotExist: 17 | return super(SettingsManager, self).create() 18 | 19 | 20 | class Settings(models.Model): 21 | """Store application-wide settings 22 | 23 | Each field is one setting. Only once instance of Settings can be created. 24 | 25 | The model is intentionally named Settings rather than Setting (as would 26 | be the Django convention), as a single model holds many settings. 27 | """ 28 | default_currency = models.CharField(max_length=3, choices=CURRENCY_CHOICES, default='EUR') 29 | additional_currencies = ArrayField(base_field=models.CharField(choices=CURRENCY_CHOICES, default=[], max_length=3), 30 | choices=CURRENCY_CHOICES, # needed? 31 | default=[], blank=True) 32 | payment_information = models.TextField(default='', blank=True, 33 | help_text='Enter information on how payment should be made, such as the ' 34 | 'bank account details housemates should pay into.') 35 | email_from_address = models.EmailField(default='', blank=True, 36 | help_text='What email address should emails appear to be sent from?') 37 | use_https = models.BooleanField(default=False) 38 | 39 | tellerio_enable = models.BooleanField(default=False, verbose_name='Enable daily teller.io imports') 40 | tellerio_token = models.CharField(max_length=100) 41 | tellerio_account_id = models.CharField(max_length=100) 42 | 43 | from_email = models.EmailField(default='', blank=True) 44 | smtp_host = models.CharField(max_length=100, default='', blank=True) 45 | smtp_port = models.IntegerField(default=None, blank=True, null=True) 46 | smtp_user = models.CharField(max_length=100, default='', blank=True) 47 | smtp_password = models.CharField(max_length=100, default='', blank=True) 48 | smtp_use_tls = models.BooleanField(default=False) 49 | smtp_use_ssl = models.BooleanField(default=False) 50 | smtp_subject_prefix = models.CharField(max_length=100, default='[swiftwind] ', blank=True) 51 | 52 | objects = SettingsManager() 53 | 54 | class Meta: 55 | verbose_name_plural = 'settings' 56 | 57 | def save(self, *args, **kwargs): 58 | if not self.pk and Settings.objects.exists(): 59 | raise CannotCreateMultipleSettingsInstances('Only one Settings instance maybe created') 60 | super(Settings, self).save(*args, **kwargs) 61 | # TODO: Push changes into cache (possibly following a refresh_from_db() call) 62 | 63 | @property 64 | def currencies(self): 65 | return sorted({self.default_currency} | set(self.additional_currencies)) 66 | -------------------------------------------------------------------------------- /swiftwind/costs/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-10-01 17:04 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import django.utils.timezone 8 | import django_smalluuid.models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [ 16 | ('hordak', '0003_check_zero_amount_20160907_0921'), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='RecurringCost', 22 | fields=[ 23 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 24 | ('uuid', django_smalluuid.models.SmallUUIDField(default=django_smalluuid.models.UUIDDefault(), editable=False, unique=True)), 25 | ('timestamp', models.DateTimeField(default=django.utils.timezone.now, editable=False)), 26 | ('is_active', models.BooleanField(default=True)), 27 | ('fixed_amount', models.DecimalField(decimal_places=2, max_digits=13)), 28 | ('total_billing_cycles', models.PositiveIntegerField(blank=True, default=None, help_text='Stop billing after this many billing cycles.', null=True)), 29 | ('type', models.CharField(choices=[('normal', 'We will not have spent this yet. We will estimate a fixed amount per billing cycle.'), ('arrears_balance', "We will have already spent this in the previous billing cycle, so bill the account's balance."), ('arrears_balance', 'We will have already spent this in the previous cycle, so bill the total amount spent in the previous cycle.')], default='normal', max_length=20)), 30 | ], 31 | ), 32 | migrations.CreateModel( 33 | name='RecurringCostSplit', 34 | fields=[ 35 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 36 | ('uuid', django_smalluuid.models.SmallUUIDField(default=django_smalluuid.models.UUIDDefault(), editable=False, unique=True)), 37 | ('portion', models.DecimalField(decimal_places=2, default=1, max_digits=13)), 38 | ('from_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hordak.Account')), 39 | ('recurring_cost', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='splits', to='costs.RecurringCost')), 40 | ], 41 | ), 42 | migrations.AddField( 43 | model_name='recurringcost', 44 | name='from_accounts', 45 | field=models.ManyToManyField(related_name='outbound_costs', through='costs.RecurringCostSplit', to='hordak.Account'), 46 | ), 47 | migrations.AddField( 48 | model_name='recurringcost', 49 | name='to_account', 50 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inbound_costs', to='hordak.Account'), 51 | ), 52 | migrations.AlterUniqueTogether( 53 | name='recurringcostsplit', 54 | unique_together=set([('recurring_cost', 'from_account')]), 55 | ), 56 | ] 57 | -------------------------------------------------------------------------------- /swiftwind/accounts/templates/accounts/overview.html: -------------------------------------------------------------------------------- 1 | {% extends 'swiftwind/base.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block page_name %}Accounts Overview{% endblock %} 5 | {% block page_description %}{% endblock %} 6 | 7 | {% block content %} 8 | 9 |
10 |
11 | 12 | 13 |

Housemates

14 |
15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {% for account in accounts %} 29 | {% if account.display_type == 'housemate' %} 30 | 31 | 36 | 37 | 38 | 45 | 46 | {% endif %} 47 | {% endfor %} 48 | 49 |
BalanceLast transactionPayment since last bill?
32 | 33 | {% firstof account.name 'Unnamed account' %} 34 | 35 | {{ account.simple_balance }}{% firstof account.latest_transaction_date '-' %} 39 | {% if account.payment_since_last_bill %} 40 | 41 | {% else %} 42 | 43 | {% endif %} 44 |
50 |
51 |
52 | 53 | 54 |
55 |
56 | 57 |

Expenses

58 |
59 |
60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | {% for account in accounts %} 72 | {% if account.display_type == 'expense' %} 73 | 74 | 77 | 78 | 79 | 80 | {% endif %} 81 | {% endfor %} 82 | 83 |
BalanceLast transaction
75 | {{ account.name }} 76 | {{ account.simple_balance }}{% firstof account.latest_transaction_date '-' %}
84 |
85 |
86 | 87 | 88 | 89 | 90 | {% endblock %} 91 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/cycles.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | 3 | from dateutil.relativedelta import relativedelta 4 | from django.utils.datetime_safe import date 5 | from django.utils.module_loading import import_string 6 | from django.conf import settings 7 | 8 | try: 9 | from functools import lru_cache 10 | except ImportError: 11 | # Not available in Python 2.7 12 | lru_cache = lambda: lambda f: f 13 | 14 | 15 | @lru_cache() 16 | def get_billing_cycle(): 17 | """ 18 | 19 | Returns: 20 | BaseCycle: 21 | """ 22 | return import_string(settings.SWIFTWIND_BILLING_CYCLE)() 23 | 24 | 25 | class BaseCycle(object): 26 | 27 | def get_next_cycle_start_date(self, as_of, inclusive): 28 | """Get the starting date of the next cycle following `as_of` 29 | 30 | Args: 31 | as_of (date): 32 | inclusive (bool): 33 | """ 34 | raise NotImplemented() 35 | 36 | def get_previous_cycle_start_date(self, as_of, inclusive): 37 | """Get the starting date of the most cycle that most recently started prior to `as_of` 38 | 39 | Args: 40 | as_of (date): 41 | inclusive (bool): 42 | """ 43 | raise NotImplemented() 44 | 45 | def get_cycle_end_date(self, start_date): 46 | """Get the end date for a cycle which begins on `start_date` 47 | 48 | Args: 49 | start_date (date): 50 | """ 51 | raise NotImplemented() 52 | 53 | def generate_date_ranges(self, as_of, inclusive=False, omit_current=False, stop_date=None): 54 | """ 55 | 56 | 57 | Args: 58 | as_of (date): Begin generating ranges on the first start date after `as_of` 59 | inclusive (bool): May the first start date be the date specified by `as_of`? 60 | omit_current (bool): If True, don't generate a date range containing `as_of`. 61 | stop_date (date): Stop iterating after this date. Will generate results 62 | infinitely if None. 63 | """ 64 | while True: 65 | if omit_current: 66 | start_date = self.get_next_cycle_start_date(as_of, inclusive) 67 | else: 68 | start_date = self.get_previous_cycle_start_date(as_of, inclusive) 69 | 70 | end_date = self.get_cycle_end_date(start_date) 71 | as_of = end_date 72 | inclusive = True 73 | 74 | if stop_date and start_date > stop_date: 75 | # Gone far enough now, time to stop generating 76 | raise StopIteration() 77 | 78 | yield start_date, end_date 79 | 80 | 81 | class Monthly(BaseCycle): 82 | 83 | def get_next_cycle_start_date(self, as_of, inclusive): 84 | if inclusive and as_of.day == 1: 85 | return copy(as_of) 86 | else: 87 | return date(year=as_of.year, month=as_of.month + 1, day=1) 88 | 89 | def get_previous_cycle_start_date(self, as_of, inclusive): 90 | if inclusive and as_of.day == 1: 91 | return copy(as_of) 92 | else: 93 | return date(year=as_of.year, month=as_of.month, day=1) 94 | 95 | def get_cycle_end_date(self, start_date): 96 | next_month = start_date + relativedelta(months=1) 97 | return date( 98 | year=next_month.year, 99 | month=next_month.month, 100 | day=1, 101 | ) 102 | -------------------------------------------------------------------------------- /swiftwind/settings/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.sites.models import _simple_domain_name_validator, Site 3 | from djmoney.settings import CURRENCY_CHOICES 4 | 5 | from swiftwind.settings.models import Settings 6 | 7 | 8 | class GeneralSettingsForm(forms.ModelForm): 9 | default_currency = forms.ChoiceField(choices=CURRENCY_CHOICES, initial='EUR') 10 | additional_currencies = forms.MultipleChoiceField(choices=CURRENCY_CHOICES, widget=forms.SelectMultiple(), 11 | required=False) 12 | 13 | class Meta: 14 | model = Settings 15 | fields = [ 16 | 'default_currency', 17 | 'additional_currencies', 18 | 'payment_information', 19 | ] 20 | 21 | 22 | class TechnicalSettingsForm(forms.ModelForm): 23 | site_name = forms.CharField(max_length=50, initial='Swiftwind', 24 | help_text='What name shall we display to users of this software?') 25 | site_domain = forms.CharField(max_length=100, validators=[_simple_domain_name_validator], 26 | help_text='What is the domain name you use for this site?') 27 | use_https = forms.BooleanField(initial=False, required=False, 28 | help_text='Is this site being served over HTTPS?') 29 | 30 | class Meta: 31 | model = Settings 32 | fields = [ 33 | 'use_https', 34 | ] 35 | 36 | def __init__(self, *args, **kwargs): 37 | self.site = Site.objects.get() 38 | initial = kwargs.get('initial') or {} 39 | initial.setdefault('site_name', self.site.name) 40 | initial.setdefault('site_domain', self.site.domain) 41 | kwargs.update(initial=initial) 42 | 43 | super().__init__(*args, **kwargs) 44 | 45 | def save(self, commit=True): 46 | obj = super().save(commit=commit) 47 | self.site.name = self.cleaned_data['site_name'] 48 | self.site.domain = self.cleaned_data['site_domain'] 49 | self.site.save() 50 | return obj 51 | 52 | 53 | class EmailSettingsForm(forms.ModelForm): 54 | from_email = forms.CharField(required=True, help_text='Address to send emails from') 55 | smtp_host = forms.CharField(required=True, label='SMTP host') 56 | smtp_port = forms.IntegerField(required=True, label='SMTP port') 57 | smtp_user = forms.CharField(label='SMTP user', required=False) 58 | smtp_password = forms.CharField(widget=forms.PasswordInput(), label='SMTP password', 59 | help_text='This password will not be redisplayed once saved.', 60 | required=False) 61 | smtp_use_ssl = forms.BooleanField(initial=True, label='Use SSL', required=False) 62 | smtp_use_tls = forms.BooleanField(initial=True, label='Use TLS', required=False) 63 | smtp_subject_prefix = forms.CharField(initial='[Swiftwind] ') 64 | 65 | class Meta: 66 | model = Settings 67 | fields = [ 68 | 'from_email', 69 | 'smtp_host', 70 | 'smtp_port', 71 | 'smtp_user', 72 | 'smtp_password', 73 | 'smtp_use_ssl', 74 | 'smtp_use_tls', 75 | 'smtp_subject_prefix', 76 | ] 77 | 78 | 79 | class TellerSettingsForm(forms.ModelForm): 80 | tellerio_token = forms.CharField(max_length=100, widget=forms.PasswordInput) 81 | 82 | class Meta: 83 | model = Settings 84 | fields = [ 85 | 'tellerio_token', 86 | 'tellerio_account_id', 87 | 'tellerio_enable', 88 | ] 89 | -------------------------------------------------------------------------------- /deploy/charts/swiftwind/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Deployment 3 | metadata: 4 | name: {{ template "swiftwind.fullname" . }} 5 | labels: 6 | app: {{ template "swiftwind.name" . }} 7 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} 8 | release: {{ .Release.Name }} 9 | heritage: {{ .Release.Service }} 10 | spec: 11 | replicas: {{ .Values.replicaCount }} 12 | template: 13 | metadata: 14 | labels: 15 | app: {{ template "swiftwind.name" . }} 16 | release: {{ .Release.Name }} 17 | spec: 18 | initContainers: 19 | - name: migrations-{{ .Chart.Name }} 20 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 21 | imagePullPolicy: {{ .Values.image.pullPolicy }} 22 | env: 23 | {{- if .Values.postgresql.enabled }} 24 | - name: POSTGRES_PASSWORD 25 | valueFrom: 26 | secretKeyRef: 27 | # We shouldn't hard code 'postgresql' here, but the following: 28 | # name: {{ template "postgresql.fullname" . }} 29 | # ...kept printing "bills-swiftwind" rather than "bills-postgresql" 30 | name: {{ printf "%s-postgresql" .Release.Name | trunc 63 | trimSuffix "-" }} 31 | key: postgres-password 32 | - name: POSTGRES_USER 33 | value: {{ default "postgres" .Values.postgresql.postgresUser | quote }} 34 | - name: POSTGRES_DB 35 | value: {{ default "" .Values.postgresql.postgresDatabase | quote }} 36 | - name: POSTGRES_HOST 37 | # See note above 38 | value: {{ printf "%s-postgresql" .Release.Name | trunc 63 | trimSuffix "-" }} 39 | - name: POSTGRES_PORT 40 | value: {{ .Values.postgresql.service.port | quote }} 41 | {{- else }} 42 | - name: DATABASE_URL 43 | value: {{ .Values.database_url | quote }} 44 | {{- end }} 45 | command: ['./manage.py', 'migrate', '--noinput'] 46 | containers: 47 | - name: {{ .Chart.Name }} 48 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 49 | imagePullPolicy: {{ .Values.image.pullPolicy }} 50 | env: 51 | {{- if .Values.postgresql.enabled }} 52 | - name: POSTGRES_PASSWORD 53 | valueFrom: 54 | secretKeyRef: 55 | # We shouldn't hard code 'postgresql' here, but the following: 56 | # name: {{ template "postgresql.fullname" . }} 57 | # ...kept printing "bills-swiftwind" rather than "bills-postgresql" 58 | name: {{ printf "%s-postgresql" .Release.Name | trunc 63 | trimSuffix "-" }} 59 | key: postgres-password 60 | - name: POSTGRES_USER 61 | value: {{ default "postgres" .Values.postgresql.postgresUser | quote }} 62 | - name: POSTGRES_DB 63 | value: {{ default "" .Values.postgresql.postgresDatabase | quote }} 64 | - name: POSTGRES_HOST 65 | # See note above 66 | value: {{ printf "%s-postgresql" .Release.Name | trunc 63 | trimSuffix "-" }} 67 | - name: POSTGRES_PORT 68 | value: {{ .Values.postgresql.service.port | quote }} 69 | {{- else }} 70 | - name: DATABASE_URL 71 | value: {{ .Values.database_url | quote }} 72 | {{- end }} 73 | 74 | ports: 75 | - containerPort: {{ .Values.service.internalPort }} 76 | livenessProbe: 77 | initialDelaySeconds: 20 78 | httpGet: 79 | path: / 80 | port: {{ .Values.service.internalPort }} 81 | readinessProbe: 82 | initialDelaySeconds: 10 83 | httpGet: 84 | path: / 85 | port: {{ .Values.service.internalPort }} 86 | resources: 87 | {{ toYaml .Values.resources | indent 12 }} 88 | {{- if .Values.nodeSelector }} 89 | nodeSelector: 90 | {{ toYaml .Values.nodeSelector | indent 8 }} 91 | {{- end }} 92 | -------------------------------------------------------------------------------- /swiftwind/core/management/commands/swiftwind_create_accounts.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.management.base import BaseCommand, CommandError 3 | 4 | from hordak.models import Account 5 | 6 | from swiftwind.settings.models import Settings 7 | 8 | 9 | class Command(BaseCommand): 10 | help = 'Create the initial chart of accounts' 11 | 12 | def add_arguments(self, parser): 13 | super(Command, self).add_arguments(parser) 14 | parser.add_argument( 15 | '--preserve', dest='preserve', default=False, action='store_true', 16 | help='Exit normally if any accounts already exist.', 17 | ) 18 | parser.add_argument( 19 | '--currency', dest='currency', 20 | help='Account currencies, can be specified multiple times. Defaults to the default currency setting.', 21 | nargs='+', 22 | ) 23 | 24 | def handle(self, *args, **options): 25 | if options.get('preserve') and Account.objects.count(): 26 | self.stdout.write('Exiting normally because accounts already exist and --preserve flag was present') 27 | 28 | if options.get('currency'): 29 | currency = options['currency'] 30 | else: 31 | try: 32 | currency = Settings.objects.get().default_currency 33 | except Settings.DoesNotExist: 34 | raise CommandError('No currency specified by either --currency or by the swiftwind settings.') 35 | 36 | kw = dict(currencies=currency) 37 | 38 | T = Account.TYPES 39 | assets = Account.objects.create(name='Assets', code='1', type=T.asset, **kw) 40 | liabilities = Account.objects.create(name='Liabilities', code='2', type=T.liability, **kw) 41 | equity = Account.objects.create(name='Equity', code='3', type=T.equity, **kw) 42 | income = Account.objects.create(name='Income', code='4', type=T.income, **kw) 43 | expenses = Account.objects.create(name='Expenses', code='5', type=T.expense, **kw) 44 | 45 | bank = Account.objects.create(name='Bank', code='1', is_bank_account=True, type='AS', parent=assets, **kw) 46 | 47 | housemate_income = Account.objects.create(name='Housemate Income', code='1', parent=income, **kw) 48 | other_income = Account.objects.create(name='Other Income', code='2', parent=income, **kw) 49 | 50 | current_liabilities = Account.objects.create(name='Current Liabilities', code='1', parent=liabilities, **kw) 51 | long_term_liabilities = Account.objects.create(name='Long Term Liabilities', code='2', parent=liabilities, **kw) 52 | 53 | gas_payable = Account.objects.create(name='Gas Payable', code='1', parent=current_liabilities, **kw) 54 | electricity_payable = Account.objects.create(name='Electricity Payable', code='2', parent=current_liabilities, **kw) 55 | council_tax_payable = Account.objects.create(name='Council Tax Payable', code='3', parent=current_liabilities, **kw) 56 | internet_payable = Account.objects.create(name='Internet Payable', code='4', parent=current_liabilities, **kw) 57 | 58 | retained_earnings = Account.objects.create(name='Retained Earnings', code='1', parent=equity, **kw) 59 | 60 | rent = Account.objects.create(name='Rent', code='1', parent=expenses, **kw) 61 | utilities = Account.objects.create(name='Utilities', code='2', parent=expenses, **kw) 62 | food = Account.objects.create(name='Food', code='3', parent=expenses, **kw) 63 | other_expenses = Account.objects.create(name='Other Expenses', code='4', parent=expenses, **kw) 64 | 65 | gas_expense = Account.objects.create(name='Gas Expense', code='1', parent=utilities, **kw) 66 | electricity_expense = Account.objects.create(name='Electricity Expense', code='2', parent=utilities, **kw) 67 | council_tax_expense = Account.objects.create(name='Council Tax Expense', code='3', parent=utilities, **kw) 68 | internet_expense = Account.objects.create(name='Internet Expense', code='4', parent=utilities, **kw) 69 | 70 | -------------------------------------------------------------------------------- /swiftwind/core/templates/adminlte/lib/_main_sidebar.html: -------------------------------------------------------------------------------- 1 | {% extends 'adminlte/lib/_main_sidebar.html' %} 2 | {% load nav hordak %} 3 | 4 | {% block nav_links %} 5 |
  • Dashboard
  • 6 | 7 |
  • 8 | 9 | 10 | Reconcile Transactions 11 | 12 | {% total_unreconciled as t %} 13 | {{ t }} 14 | 15 | 16 |
  • 17 | 18 |
  • Create Transaction
  • 19 |
  • Recurring Costs
  • 20 |
  • One-off Costs
  • 21 |
  • Housemates
  • 22 | 23 |
  • 24 | 25 | 26 | Housemate Accounts 27 | 28 | 29 | 30 | 31 | 32 | 51 |
  • 52 | 53 |
  • Billing cycles
  • 54 | 55 |
  • 56 | 57 | 58 | Settings 59 | 60 | 61 | 62 | 63 | 64 | 70 |
  • 71 | 72 |
  • Import Statement
  • 73 | 74 |
  • 75 | 76 | 77 | Extra 78 | 79 | 80 | 81 | 82 | 83 | 94 |
  • 95 | {% endblock nav_links %} 96 | -------------------------------------------------------------------------------- /swiftwind/billing_cycle/templates/billing_cycle/list.html: -------------------------------------------------------------------------------- 1 | {% extends 'swiftwind/base.html' %} 2 | {% load bootstrap3 %} 3 | 4 | {% block page_name %}Billing cycles{% endblock %} 5 | {% block page_description %}{% endblock %} 6 | 7 | {% block content %} 8 |
    9 |
    10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% for billing_cycle in billing_cycles %} 24 | 25 | 26 | 27 | 34 | 41 | 48 | 73 | 81 | 82 | {% endfor %} 83 | 84 |
    Start dateEnd dateTransactions createdIs reconciledStatements sent
    {{ billing_cycle.date_range.lower }}{{ billing_cycle.date_range.upper }} 28 | {% if billing_cycle.transactions_created %} 29 | 30 | {% else %} 31 | 32 | {% endif %} 33 | 35 | {% if billing_cycle.is_reconciled %} 36 | 37 | {% else %} 38 | 39 | {% endif %} 40 | 42 | {% if billing_cycle.statements_sent %} 43 | 44 | {% else %} 45 | 46 | {% endif %} 47 | 49 | {% if billing_cycle.transactions_created %} 50 |
    51 | {% csrf_token %} 52 |
    53 | 54 | 58 | 61 |
    62 |
    63 |
    64 | {% csrf_token %} 65 |
    66 | {% elif billing_cycle.can_create_transactions %} 67 |
    68 | {% csrf_token %} 69 | 70 |
    71 | {% endif %} 72 |
    74 | {% if billing_cycle.can_send_statements %} 75 |
    76 | {% csrf_token %} 77 | 78 |
    79 | {% endif %} 80 |
    85 | 86 |
    87 |
    88 | {% endblock %} 89 | -------------------------------------------------------------------------------- /swiftwind/core/templates/swiftwind/base_email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block title %}{% endblock %} 7 | 88 | 89 | 90 | 91 | 92 | 93 | 121 | 122 | 123 |
      94 |
    95 | 96 | 97 | 100 | 101 | 102 | 103 | 104 | 113 | 114 | 115 | 116 |
    105 | 106 | 107 | 110 | 111 |
    108 | {% block content %}{% endblock %} 109 |
    112 |
    117 | 118 | 119 |
    120 |
     
    124 | 125 | 126 | -------------------------------------------------------------------------------- /swiftwind/system_setup/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.auth import get_user_model 3 | from django.contrib.auth.forms import UsernameField 4 | from django.contrib.sites.models import _simple_domain_name_validator, Site 5 | from django.core.exceptions import ValidationError 6 | from djmoney.money import Money 7 | from djmoney.settings import CURRENCY_CHOICES 8 | 9 | from hordak.models import Account 10 | from swiftwind.billing_cycle.models import BillingCycle 11 | from swiftwind.core.management.commands.swiftwind_create_accounts import Command as SwiftwindCreateAccountsCommand 12 | from swiftwind.settings.models import Settings 13 | from swiftwind.housemates.models import Housemate 14 | 15 | User = get_user_model() 16 | 17 | 18 | class SetupForm(forms.Form): 19 | first_name = forms.CharField() 20 | last_name = forms.CharField() 21 | email = forms.EmailField() 22 | username = UsernameField() 23 | password1 = forms.CharField(label='Password', strip=False, widget=forms.PasswordInput) 24 | password2 = forms.CharField(label='Password confirmation', widget=forms.PasswordInput, strip=False, 25 | help_text='Enter the same password as before, for verification.') 26 | 27 | default_currency = forms.ChoiceField(choices=CURRENCY_CHOICES, initial='EUR') 28 | additional_currencies = forms.MultipleChoiceField(choices=CURRENCY_CHOICES, widget=forms.SelectMultiple(), 29 | required=False) 30 | accounting_start_date = forms.DateField( 31 | help_text='When should we start accounting from?' 32 | ) 33 | opening_bank_balance = forms.DecimalField(min_value=0, max_digits=13, decimal_places=2, 34 | initial='0.00', 35 | help_text='Enter your opening bank balance if you are ' 36 | 'moving over from an existing accounting system.' 37 | 'Ignore otherwise.') 38 | 39 | site_name = forms.CharField(max_length=50, initial='Swiftwind', 40 | help_text='What name shall we display to users of this software?') 41 | site_domain = forms.CharField(max_length=100, validators=[_simple_domain_name_validator], 42 | help_text='What is the domain name you will use for this site? ' 43 | 'If unsure leave at the default value.') 44 | use_https = forms.BooleanField(initial=False, required=False, 45 | help_text='Is this site being served over HTTPS? ' 46 | 'If unsure leave at the default value.') 47 | 48 | def clean(self): 49 | if Settings.objects.exists(): 50 | raise ValidationError('Swiftwind has already been setup') 51 | 52 | if self.cleaned_data['password1'] != self.cleaned_data['password2']: 53 | raise ValidationError('Passwords do not match') 54 | 55 | return super().clean() 56 | 57 | def clean_username(self): 58 | if User.objects.filter(username=self.cleaned_data['username']).exists(): 59 | raise ValidationError('That username already exists') 60 | return self.cleaned_data['username'] 61 | 62 | def save(self): 63 | # Create the superuser 64 | user = User.objects.create( 65 | first_name=self.cleaned_data['first_name'], 66 | last_name=self.cleaned_data['last_name'], 67 | email=self.cleaned_data['email'], 68 | username=self.cleaned_data['username'], 69 | is_superuser=True, 70 | is_staff=True, 71 | ) 72 | user.set_password(self.cleaned_data['password1']) 73 | user.save() 74 | 75 | # Save the settings 76 | db_settings = Settings.objects.get() 77 | db_settings.default_currency = self.cleaned_data['default_currency'] 78 | db_settings.additional_currencies = self.cleaned_data['additional_currencies'] 79 | db_settings.use_https = self.cleaned_data['use_https'] 80 | db_settings.save() 81 | 82 | # Save the site details 83 | Site.objects.update_or_create(defaults=dict( 84 | domain=self.cleaned_data['site_domain'], 85 | name=self.cleaned_data['site_name'], 86 | )) 87 | 88 | # Create the initial accounts 89 | if not Account.objects.exists(): 90 | SwiftwindCreateAccountsCommand().handle( 91 | currency=self.cleaned_data['default_currency'], 92 | ) 93 | 94 | # Create a housemate & account 95 | account = Account.objects.create( 96 | name=user.get_full_name() or user.username, 97 | parent=Account.objects.get(name='Housemate Income'), 98 | code='00', 99 | currencies=[self.cleaned_data['default_currency']], 100 | ) 101 | Housemate.objects.create( 102 | account=account, 103 | user=user, 104 | ) 105 | 106 | # Create opening balance account 107 | if self.cleaned_data['opening_bank_balance']: 108 | opening_balance_account = Account.objects.create( 109 | name='Opening Balance', 110 | code='99', 111 | currencies=[self.cleaned_data['default_currency']], 112 | type=Account.TYPES.income, 113 | ) 114 | opening_balance_account.transfer_to( 115 | Account.objects.get(name='Bank'), 116 | amount=Money(self.cleaned_data['opening_bank_balance'], self.cleaned_data['default_currency']) 117 | ) 118 | 119 | # Create the billing cycles 120 | BillingCycle.populate(as_of=self.cleaned_data['accounting_start_date']) 121 | -------------------------------------------------------------------------------- /swiftwind/system_setup/tests.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from django.contrib.auth.models import User 3 | from django.contrib.sites.models import Site 4 | from django.test import TestCase 5 | from django.test.utils import override_settings 6 | from django.urls import reverse 7 | 8 | from hordak.models.core import Account, Transaction 9 | from hordak.utilities.currency import Balance 10 | from swiftwind.billing_cycle.models import BillingCycle 11 | from swiftwind.settings.models import Settings 12 | from swiftwind.housemates.models import Housemate 13 | from swiftwind.utilities.testing import DataProvider 14 | 15 | 16 | class SetupViewTestCase(DataProvider, TestCase): 17 | 18 | def setUp(self): 19 | self.view_url = reverse('setup:index') 20 | 21 | @override_settings(SECURE_PROXY_SSL_HEADER=('HTTP_X_FORWARDED_PROTO', 'https')) 22 | def test_get(self): 23 | response = self.client.get(self.view_url, 24 | HTTP_HOST='mysite.com', 25 | SERVER_PORT=8080, 26 | HTTP_X_FORWARDED_PROTO='https') 27 | self.assertEqual(response.status_code, 200) 28 | context = response.context 29 | 30 | self.assertIn('form', context) 31 | self.assertEqual(context['form']['site_domain'].initial, 'mysite.com:8080') 32 | self.assertEqual(context['form']['use_https'].initial, True) 33 | 34 | def test_post_valid(self): 35 | response = self.client.post(self.view_url, data={ 36 | 'first_name': 'First', 37 | 'last_name': 'Last', 38 | 'email': 'test@example.com', 39 | 'username': 'testuser', 40 | 'password1': 'mypassword', 41 | 'password2': 'mypassword', 42 | 'default_currency': 'GBP', 43 | 'additional_currencies': ['EUR', 'USD'], 44 | 'site_name': 'My Site', 45 | 'site_domain': 'mysite.com', 46 | 'use_https': 'yes', 47 | 'opening_bank_balance': '0.00', 48 | 'accounting_start_date': '2000-01-01', 49 | }) 50 | context = response.context 51 | if response.context: 52 | self.assertFalse(context['form'].errors) 53 | 54 | user = User.objects.get() 55 | self.assertEqual(user.first_name, 'First') 56 | self.assertEqual(user.last_name, 'Last') 57 | self.assertEqual(user.email, 'test@example.com') 58 | self.assertEqual(user.username, 'testuser') 59 | self.assertTrue(user.check_password('mypassword')) 60 | self.assertTrue(user.is_superuser) 61 | self.assertTrue(user.is_staff) 62 | 63 | settings = Settings.objects.get() 64 | self.assertEqual(settings.default_currency, 'GBP') 65 | self.assertEqual(settings.additional_currencies, ['EUR', 'USD']) 66 | self.assertTrue(settings.use_https) 67 | 68 | self.assertEqual(int(self.client.session['_auth_user_id']), user.pk) 69 | 70 | housemate = Housemate.objects.get() 71 | self.assertEqual(housemate.user, user) 72 | self.assertEqual(housemate.account.type, Account.TYPES.income) 73 | 74 | site = Site.objects.get() 75 | self.assertEqual(site.domain, 'mysite.com') 76 | self.assertEqual(site.name, 'My Site') 77 | 78 | # Check no opening balance account has been created 79 | self.assertFalse(Account.objects.filter(name='Opening Balance').exists()) 80 | self.assertFalse(Transaction.objects.all().exists()) 81 | 82 | # Check accounting start date was used 83 | self.assertEqual(BillingCycle.objects.first().date_range.lower, date(2000, 1, 1)) 84 | self.assertEqual(BillingCycle.objects.first().date_range.upper, date(2000, 2, 1)) 85 | 86 | def test_can_load_dashboard_after_setup(self): 87 | self.client.post(self.view_url, data={ 88 | 'first_name': 'First', 89 | 'last_name': 'Last', 90 | 'email': 'test@example.com', 91 | 'username': 'testuser', 92 | 'password1': 'mypassword', 93 | 'password2': 'mypassword', 94 | 'default_currency': 'GBP', 95 | 'additional_currencies': ['EUR', 'USD'], 96 | 'site_name': 'My Site', 97 | 'site_domain': 'mysite.com', 98 | 'use_https': 'yes', 99 | 'opening_bank_balance': '0.00', 100 | 'accounting_start_date': '2000-01-01', 101 | }) 102 | 103 | # Now check we can load the dashboard 104 | response = self.client.get(reverse('dashboard:dashboard')) 105 | self.assertEqual(response.status_code, 200) 106 | 107 | def test_post_opening_balance(self): 108 | response = self.client.post(self.view_url, data={ 109 | 'first_name': 'First', 110 | 'last_name': 'Last', 111 | 'email': 'test@example.com', 112 | 'username': 'testuser', 113 | 'password1': 'mypassword', 114 | 'password2': 'mypassword', 115 | 'default_currency': 'GBP', 116 | 'additional_currencies': ['EUR', 'USD'], 117 | 'site_name': 'My Site', 118 | 'site_domain': 'mysite.com', 119 | 'use_https': 'yes', 120 | 'opening_bank_balance': '1234.56', 121 | 'accounting_start_date': '2000-01-01', 122 | }) 123 | 124 | opening_balance = Account.objects.get(name='Opening Balance') 125 | bank = Account.objects.get(name='Bank') 126 | self.assertEqual(opening_balance.balance(), Balance('1234.56', 'GBP')) 127 | self.assertEqual(bank.balance(), Balance('1234.56', 'GBP')) 128 | 129 | def test_get_when_already_setup(self): 130 | Settings.objects.create() 131 | response = self.client.get(self.view_url) 132 | self.assertEqual(response.status_code, 302) 133 | 134 | def test_post_when_already_setup(self): 135 | Settings.objects.create() 136 | response = self.client.post(self.view_url) 137 | self.assertEqual(response.status_code, 302) 138 | -------------------------------------------------------------------------------- /swiftwind/costs/forms.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta, date 2 | from django import forms 3 | from django.core.exceptions import ValidationError 4 | from django.db import transaction 5 | from django.utils.datetime_safe import datetime 6 | from hordak.models import Account 7 | from mptt.forms import TreeNodeChoiceField 8 | 9 | from hordak.utilities.currency import Balance 10 | from swiftwind.billing_cycle.models import BillingCycle 11 | from .models import RecurringCost, RecurringCostSplit 12 | from swiftwind.utilities.formsets import nested_model_formset_factory 13 | 14 | 15 | class AbstractCostForm(forms.ModelForm): 16 | to_account = TreeNodeChoiceField(queryset=Account.objects.all(), to_field_name='uuid') 17 | 18 | class Meta: 19 | model = RecurringCost 20 | fields = [] 21 | 22 | def __init__(self, *args, **kwargs): 23 | kwargs.setdefault('initial', {}) 24 | instance = kwargs.get('instance') 25 | if instance: 26 | kwargs['initial'].update(to_account=instance.to_account.uuid) 27 | 28 | super(AbstractCostForm, self).__init__(*args, **kwargs) 29 | 30 | @transaction.atomic() 31 | def save(self, commit=True): 32 | creating = not bool(self.instance.pk) 33 | recurring_cost = super(AbstractCostForm, self).save(commit) 34 | 35 | if creating: 36 | # TODO: Make configurable 37 | housemate_accounts = Account.objects.get(name='Housemate Income').get_children() 38 | for housemate_account in housemate_accounts: 39 | RecurringCostSplit.objects.create( 40 | recurring_cost=recurring_cost, 41 | from_account=housemate_account, 42 | ) 43 | 44 | return recurring_cost 45 | 46 | 47 | class RecurringCostForm(AbstractCostForm): 48 | type = forms.ChoiceField(choices=RecurringCost.TYPES, widget=forms.RadioSelect) 49 | 50 | class Meta(AbstractCostForm.Meta): 51 | fields = ('to_account', 'type', 'disabled', 'fixed_amount') 52 | labels = dict( 53 | disabled='Disable this recurring cost', 54 | ) 55 | 56 | def clean_fixed_amount(self): 57 | value = self.cleaned_data['fixed_amount'] 58 | if value and self.cleaned_data['type'] != RecurringCost.TYPES.normal: 59 | raise ValidationError('You cannot specify a fixed amount for the selected type of recurring cost') 60 | return value 61 | 62 | 63 | class OneOffCostForm(AbstractCostForm): 64 | fixed_amount = forms.DecimalField(required=True, label='Amount') 65 | total_billing_cycles = forms.IntegerField(required=True, label='Total Billing Cycles', initial=1) 66 | 67 | class Meta(AbstractCostForm.Meta): 68 | fields = ('to_account', 'fixed_amount', 'total_billing_cycles') 69 | 70 | def save(self, commit=True): 71 | self.instance.type = RecurringCost.TYPES.normal 72 | return super(OneOffCostForm, self).save(commit) 73 | 74 | def clean_fixed_amount(self): 75 | amount = self.cleaned_data['fixed_amount'] 76 | 77 | try: 78 | # Mirroring the simplification in RecurringCost.currency 79 | currency = self.cleaned_data['to_account'].currencies[0] 80 | except KeyError: 81 | return amount 82 | 83 | balance = Balance(amount, currency) 84 | billed_amount = self.instance.get_billed_amount() 85 | 86 | if balance < billed_amount: 87 | raise ValidationError( 88 | "This cost has already billed for {}. You therefore cannot set the amount to less than this." 89 | "".format(billed_amount) 90 | ) 91 | return amount 92 | 93 | 94 | class InitialBillingCycleMixin(object): 95 | """The creation forms need to collect initial billing cycle, hence this mixin""" 96 | 97 | def __init__(self, *args, **kwargs): 98 | kwargs.setdefault('initial', {}) 99 | kwargs['initial'].update(initial_billing_cycle=BillingCycle.objects.as_of(date.today())) 100 | super().__init__(*args, **kwargs) 101 | self.fields['initial_billing_cycle'].queryset = self.get_initial_billing_cycle_queryset() 102 | 103 | def get_initial_billing_cycle_queryset(self): 104 | return BillingCycle.objects.filter( 105 | end_date__gte=datetime.now().date() - timedelta(days=31 * 6), 106 | ) 107 | 108 | 109 | class CreateRecurringCostForm(InitialBillingCycleMixin, RecurringCostForm): 110 | 111 | class Meta(RecurringCostForm.Meta): 112 | fields = ('to_account', 'type', 'disabled', 'fixed_amount', 'initial_billing_cycle') 113 | 114 | 115 | class CreateOneOffCostForm(InitialBillingCycleMixin, OneOffCostForm): 116 | 117 | class Meta(OneOffCostForm.Meta): 118 | fields = ('to_account', 'fixed_amount', 'total_billing_cycles', 'initial_billing_cycle') 119 | 120 | 121 | class RecurringCostSplitForm(forms.ModelForm): 122 | 123 | class Meta: 124 | model = RecurringCostSplit 125 | fields = ('portion', ) 126 | 127 | 128 | RecurringCostFormSet = nested_model_formset_factory( 129 | model=RecurringCost, 130 | form=RecurringCostForm, 131 | extra=0, 132 | can_delete=False, 133 | nested_formset=forms.inlineformset_factory( 134 | parent_model=RecurringCost, 135 | model=RecurringCostSplit, 136 | form=RecurringCostSplitForm, 137 | extra=0, 138 | can_delete=False, 139 | ) 140 | ) 141 | 142 | 143 | OneOffCostFormSet = nested_model_formset_factory( 144 | model=RecurringCost, 145 | form=OneOffCostForm, 146 | extra=0, 147 | can_delete=False, 148 | nested_formset=forms.inlineformset_factory( 149 | parent_model=RecurringCost, 150 | model=RecurringCostSplit, 151 | form=RecurringCostSplitForm, 152 | extra=0, 153 | can_delete=False, 154 | ) 155 | ) 156 | 157 | 158 | RecurringCostSplitFormSet = forms.inlineformset_factory( 159 | parent_model=RecurringCost, 160 | model=RecurringCostSplit, 161 | form=RecurringCostSplitForm, 162 | extra=0, 163 | can_delete=False, 164 | ) 165 | -------------------------------------------------------------------------------- /swiftwind/costs/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.mixins import LoginRequiredMixin 2 | from django.db import transaction 3 | from django.http import HttpResponseRedirect 4 | from django.urls import reverse, reverse_lazy 5 | from django.views.generic import DetailView 6 | from django.views.generic.edit import CreateView, UpdateView, DeleteView 7 | 8 | from swiftwind.housemates.views import HousematesRequiredMixin 9 | from .forms import RecurringCostFormSet, OneOffCostFormSet, CreateOneOffCostForm, \ 10 | CreateRecurringCostForm 11 | from .models import RecurringCost 12 | 13 | 14 | class RecurringCostsView(LoginRequiredMixin, HousematesRequiredMixin, UpdateView): 15 | template_name = 'costs/recurring.html' 16 | model = RecurringCost 17 | ordering = ['is_active', 'to_account__name'] 18 | form_class = RecurringCostFormSet 19 | 20 | def get_object(self, queryset=None): 21 | return None 22 | 23 | def get_context_data(self, **kwargs): 24 | context = super(RecurringCostsView, self).get_context_data(**kwargs) 25 | context['formset'] = context['form'] 26 | context['form_action'] = self.get_success_url() 27 | context['disabled_costs'] = RecurringCost.objects.filter(disabled=True).recurring() 28 | context['archived_costs'] = RecurringCost.objects.filter(archived=True, disabled=False).recurring() 29 | return context 30 | 31 | def get_queryset(self): 32 | return RecurringCost.objects.filter(disabled=False, archived=False).recurring() 33 | 34 | def get_form_kwargs(self): 35 | kwargs = super(RecurringCostsView, self).get_form_kwargs() 36 | kwargs.pop('instance') 37 | kwargs['queryset'] = self.get_queryset() 38 | return kwargs 39 | 40 | def get_success_url(self): 41 | return reverse('costs:recurring') 42 | 43 | 44 | class CreateRecurringCostView(LoginRequiredMixin, HousematesRequiredMixin, CreateView): 45 | form_class = CreateRecurringCostForm 46 | template_name = 'costs/create_recurring.html' 47 | 48 | def get_success_url(self): 49 | return reverse('costs:recurring') 50 | 51 | 52 | class OneOffCostsView(RecurringCostsView): 53 | template_name = 'costs/one_off.html' 54 | form_class = OneOffCostFormSet 55 | 56 | def get_queryset(self): 57 | return RecurringCost.objects.filter(disabled=False, archived=False).one_off() 58 | 59 | def get_context_data(self, **kwargs): 60 | context = super().get_context_data(**kwargs) 61 | context['disabled_costs'] = RecurringCost.objects.filter(disabled=True).one_off() 62 | context['archived_costs'] = RecurringCost.objects.filter(archived=True, disabled=False).one_off() 63 | return context 64 | 65 | def get_success_url(self): 66 | return reverse('costs:one_off') 67 | 68 | 69 | class CreateOneOffCostView(LoginRequiredMixin, HousematesRequiredMixin, CreateView): 70 | form_class = CreateOneOffCostForm 71 | template_name = 'costs/create_one_off.html' 72 | 73 | def get_success_url(self): 74 | return reverse('costs:one_off') 75 | 76 | 77 | class DeleteRecurringCostView(LoginRequiredMixin, HousematesRequiredMixin, DeleteView): 78 | model = RecurringCost 79 | success_url = reverse_lazy('costs:recurring') 80 | slug_field = 'uuid' 81 | slug_url_kwarg = 'uuid' 82 | template_name = 'costs/delete_recurring.html' 83 | queryset = RecurringCost.objects.recurring() 84 | archive_url_name = 'costs:archive_recurring' 85 | context_object_name = 'cost' 86 | 87 | def archive_url(self): 88 | return reverse(self.archive_url_name, args=[self.get_object().uuid]) 89 | 90 | def get(self, request, *args, **kwargs): 91 | if self.get_object().can_delete(): 92 | return super().get(request, *args, **kwargs) 93 | else: 94 | return HttpResponseRedirect(self.archive_url()) 95 | 96 | def delete(self, request, *args, **kwargs): 97 | if self.get_object().can_delete(): 98 | with transaction.atomic(): 99 | return super().delete(request, *args, **kwargs) 100 | else: 101 | return HttpResponseRedirect(self.archive_url()) 102 | 103 | 104 | class DeleteOneOffCostView(DeleteRecurringCostView): 105 | success_url = reverse_lazy('costs:one_off') 106 | template_name = 'costs/delete_oneoff.html' 107 | queryset = RecurringCost.objects.one_off() 108 | archive_url_name = 'costs:archive_one_off' 109 | 110 | 111 | class ArchiveRecurringCostView(LoginRequiredMixin, HousematesRequiredMixin, DetailView): 112 | model = RecurringCost 113 | success_url = reverse_lazy('costs:recurring') 114 | slug_field = 'uuid' 115 | slug_url_kwarg = 'uuid' 116 | queryset = RecurringCost.objects.recurring() 117 | context_object_name = 'cost' 118 | 119 | def get(self, request, *args, **kwargs): 120 | # No get requests allowed (as we don't ask for confirmation upon archiving) 121 | return HttpResponseRedirect(self.success_url) 122 | 123 | def post(self, request, *args, **kwargs): 124 | self.get_object().archive() 125 | return HttpResponseRedirect(self.success_url) 126 | 127 | 128 | class ArchiveOneOffCostView(ArchiveRecurringCostView): 129 | success_url = reverse_lazy('costs:one_off') 130 | queryset = RecurringCost.objects.one_off() 131 | 132 | 133 | class UnarchiveRecurringCostView(LoginRequiredMixin, HousematesRequiredMixin, DetailView): 134 | model = RecurringCost 135 | success_url = reverse_lazy('costs:recurring') 136 | slug_field = 'uuid' 137 | slug_url_kwarg = 'uuid' 138 | queryset = RecurringCost.objects.recurring() 139 | context_object_name = 'cost' 140 | 141 | def get(self, request, *args, **kwargs): 142 | # No get requests allowed (as we don't ask for confirmation upon archiving) 143 | return HttpResponseRedirect(self.success_url) 144 | 145 | def post(self, request, *args, **kwargs): 146 | self.get_object().unarchive() 147 | return HttpResponseRedirect(self.success_url) 148 | 149 | 150 | class UnarchiveOneOffCostView(UnarchiveRecurringCostView): 151 | success_url = reverse_lazy('costs:one_off') 152 | queryset = RecurringCost.objects.one_off() 153 | -------------------------------------------------------------------------------- /swiftwind/utilities/formsets.py: -------------------------------------------------------------------------------- 1 | from django.forms.models import ( 2 | BaseInlineFormSet, 3 | inlineformset_factory, 4 | ModelForm, 5 | BaseModelFormSet, 6 | modelformset_factory) 7 | 8 | 9 | def _all_errors(formset): 10 | errors = [] 11 | for form in formset.forms: 12 | if hasattr(form, 'nested'): 13 | errors += _all_errors(form.nested) 14 | else: 15 | errors += form.non_field_errors() + \ 16 | [ 17 | '{}: {}: {}'.format(form.prefix, f, errors[0]) 18 | for f, errors 19 | in form.errors.items() 20 | ] 21 | return errors 22 | 23 | 24 | class NestedMixin(object): 25 | 26 | def add_fields(self, form, index): 27 | 28 | # allow the super class to create the fields as usual 29 | super(NestedMixin, self).add_fields(form, index) 30 | 31 | form.nested = self.nested_formset_class( 32 | instance=form.instance, 33 | data=form.data if form.is_bound else None, 34 | files=form.files if form.is_bound else None, 35 | prefix='%s-%s' % ( 36 | form.prefix, 37 | self.nested_formset_class.get_default_prefix(), 38 | ), 39 | ) 40 | 41 | def is_valid(self): 42 | 43 | result = super(NestedMixin, self).is_valid() 44 | 45 | if self.is_bound: 46 | # look at any nested formsets, as well 47 | for form in self.forms: 48 | if not self._should_delete_form(form): 49 | result = result and form.nested.is_valid() 50 | 51 | return result 52 | 53 | def save(self, commit=True): 54 | 55 | result = super(NestedMixin, self).save(commit=commit) 56 | 57 | for form in self.forms: 58 | if not self._should_delete_form(form): 59 | form.nested.save(commit=commit) 60 | 61 | return result 62 | 63 | def all_errors(self): 64 | """Get all errors present in this formset, recursing to any additional nested formsets.""" 65 | return _all_errors(self) 66 | 67 | @property 68 | def media(self): 69 | return self.empty_form.media + self.empty_form.nested.media 70 | 71 | 72 | class BaseNestedFormset(NestedMixin, BaseInlineFormSet): 73 | pass 74 | 75 | 76 | class BaseNestedModelFormSet(NestedMixin, BaseModelFormSet): 77 | pass 78 | 79 | 80 | class BaseNestedModelForm(ModelForm): 81 | 82 | def has_changed(self): 83 | 84 | return ( 85 | super(BaseNestedModelForm, self).has_changed() or 86 | self.nested.has_changed() 87 | ) 88 | 89 | 90 | def nested_inline_formset_factory(parent_model, model, nested_formset, 91 | form=BaseNestedModelForm, 92 | formset=BaseNestedFormset, fk_name=None, 93 | fields=None, exclude=None, extra=3, 94 | can_order=False, can_delete=True, 95 | max_num=None, formfield_callback=None, 96 | widgets=None, validate_max=False, 97 | localized_fields=None, labels=None, 98 | help_texts=None, error_messages=None, 99 | min_num=None, validate_min=None): 100 | kwargs = { 101 | 'form': form, 102 | 'formset': formset, 103 | 'fk_name': fk_name, 104 | 'fields': fields, 105 | 'exclude': exclude, 106 | 'extra': extra, 107 | 'can_order': can_order, 108 | 'can_delete': can_delete, 109 | 'max_num': max_num, 110 | 'formfield_callback': formfield_callback, 111 | 'widgets': widgets, 112 | 'validate_max': validate_max, 113 | 'localized_fields': localized_fields, 114 | 'labels': labels, 115 | 'help_texts': help_texts, 116 | 'error_messages': error_messages, 117 | 'min_num': min_num, 118 | 'validate_min': validate_min, 119 | } 120 | 121 | if kwargs['fields'] is None: 122 | kwargs['fields'] = [ 123 | field.name 124 | for field in model._meta.local_fields 125 | ] 126 | 127 | NestedInlineFormSet = inlineformset_factory( 128 | parent_model, 129 | model, 130 | **kwargs 131 | ) 132 | NestedInlineFormSet.nested_formset_class = nested_formset 133 | 134 | return NestedInlineFormSet 135 | 136 | 137 | def nested_model_formset_factory(model, nested_formset, 138 | form=BaseNestedModelForm, 139 | formset=BaseNestedModelFormSet, 140 | fields=None, exclude=None, extra=3, 141 | can_order=False, can_delete=True, 142 | max_num=None, formfield_callback=None, 143 | widgets=None, validate_max=False, 144 | localized_fields=None, labels=None, 145 | help_texts=None, error_messages=None, 146 | min_num=None, validate_min=None): 147 | kwargs = { 148 | 'form': form, 149 | 'formset': formset, 150 | 'fields': fields, 151 | 'exclude': exclude, 152 | 'extra': extra, 153 | 'can_order': can_order, 154 | 'can_delete': can_delete, 155 | 'max_num': max_num, 156 | 'formfield_callback': formfield_callback, 157 | 'widgets': widgets, 158 | 'validate_max': validate_max, 159 | 'localized_fields': localized_fields, 160 | 'labels': labels, 161 | 'help_texts': help_texts, 162 | 'error_messages': error_messages, 163 | 'min_num': min_num, 164 | 'validate_min': validate_min, 165 | } 166 | 167 | # if kwargs['fields'] is None: 168 | # kwargs['fields'] = [ 169 | # field.name 170 | # for field in model._meta.local_fields 171 | # ] 172 | 173 | NestedModelFormSet = modelformset_factory( 174 | model, 175 | **kwargs 176 | ) 177 | NestedModelFormSet.nested_formset_class = nested_formset 178 | 179 | return NestedModelFormSet 180 | -------------------------------------------------------------------------------- /example_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example_project project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.10.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.10/ref/settings/ 11 | """ 12 | 13 | import os 14 | import sys 15 | import dj_database_url 16 | from pathlib import Path 17 | 18 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 19 | BASE_DIR = Path(__file__).resolve().parent.parent 20 | 21 | 22 | # Quick-start development settings - unsuitable for production 23 | # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ 24 | 25 | # SECURITY WARNING: keep the secret key used in production secret! 26 | SECRET_KEY = '7lz1x9*1dfc_6jd-24fszneiude(t%84re6*!)kpc=ifk(7@3' 27 | 28 | # SECURITY WARNING: don't run with debug turned on in production! 29 | DEBUG = True 30 | 31 | ALLOWED_HOSTS = ['*'] 32 | 33 | INTERNAL_IPS = ['127.0.0.1'] 34 | 35 | # Application definition 36 | 37 | INSTALLED_APPS = [ 38 | 'django.contrib.auth', 39 | 'django.contrib.contenttypes', 40 | 'django.contrib.sessions', 41 | 'django.contrib.sites', 42 | 'django.contrib.messages', 43 | 'django.contrib.staticfiles', 44 | 'django.contrib.admin', 45 | # 'django.contrib.admindocs', 46 | 47 | 'bootstrap3', 48 | 'mptt', 49 | 'django_extensions', 50 | 'django_celery_beat', 51 | 52 | 'swiftwind.core', 53 | 'swiftwind.accounts', 54 | 'swiftwind.billing_cycle', 55 | 'swiftwind.bills', 56 | 'swiftwind.costs', 57 | 'swiftwind.dashboard', 58 | 'swiftwind.housemates', 59 | 'swiftwind.settings', 60 | 'swiftwind.system_setup', 61 | 'swiftwind.transactions', 62 | 63 | 'hordak', 64 | 'django_adminlte', 65 | ] 66 | 67 | MIDDLEWARE = [ 68 | 'django.middleware.security.SecurityMiddleware', 69 | 'django.contrib.sessions.middleware.SessionMiddleware', 70 | 'django.middleware.common.CommonMiddleware', 71 | 'django.middleware.csrf.CsrfViewMiddleware', 72 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 73 | 'django.contrib.messages.middleware.MessageMiddleware', 74 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 75 | 'swiftwind.system_setup.middleware.CheckSetupDoneMiddleware', 76 | ] 77 | 78 | if 'test' in sys.argv: 79 | MIDDLEWARE.remove('swiftwind.system_setup.middleware.CheckSetupDoneMiddleware') 80 | 81 | ROOT_URLCONF = 'example_project.urls' 82 | 83 | TEMPLATES = [ 84 | { 85 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 86 | 'DIRS': [], 87 | 'APP_DIRS': True, 88 | 'OPTIONS': { 89 | 'context_processors': [ 90 | 'django.template.context_processors.debug', 91 | 'django.template.context_processors.request', 92 | 'django.contrib.auth.context_processors.auth', 93 | 'django.contrib.messages.context_processors.messages', 94 | ], 95 | }, 96 | }, 97 | ] 98 | 99 | WSGI_APPLICATION = 'example_project.wsgi.application' 100 | 101 | 102 | DATABASES = { 103 | # Configure by setting the DATABASE_URL environment variable. 104 | # The default settings may work well for local development. 105 | 'default': dj_database_url.config() or { 106 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 107 | 'NAME': os.environ.get('POSTGRES_DB', 'postgres'), 108 | 'HOST': os.environ.get('POSTGRES_HOST', '127.0.0.1'), 109 | 'PORT': os.environ.get('POSTGRES_PORT', '5432'), 110 | 'USER': os.environ.get('POSTGRES_USER', 'postgres'), 111 | 'PASSWORD': os.environ.get('POSTGRES_PASSWORD', ''), 112 | } 113 | } 114 | 115 | 116 | # Password validation 117 | # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators 118 | 119 | AUTH_PASSWORD_VALIDATORS = [ 120 | { 121 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 122 | }, 123 | { 124 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 125 | }, 126 | { 127 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 128 | }, 129 | { 130 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 131 | }, 132 | ] 133 | 134 | 135 | # Internationalization 136 | # https://docs.djangoproject.com/en/1.10/topics/i18n/ 137 | 138 | LANGUAGE_CODE = os.environ.get('LANGUAGE_CODE', 'en-us') 139 | 140 | TIME_ZONE = 'UTC' 141 | 142 | USE_I18N = True 143 | 144 | USE_L10N = True 145 | 146 | USE_TZ = True 147 | 148 | SITE_ID = 1 149 | 150 | # Static files (CSS, JavaScript, Images) 151 | # https://docs.djangoproject.com/en/1.10/howto/static-files/ 152 | 153 | STATIC_URL = '/static/' 154 | STATIC_ROOT = BASE_DIR / '.static' 155 | 156 | MEDIA_URL = '/media/' 157 | MEDIA_ROOT = BASE_DIR / '.media' 158 | 159 | LOGIN_URL = '/auth/login/' 160 | 161 | # Celery 162 | CELERY_ACCEPT_CONTENT = ['json'] 163 | CELERY_TASK_SERIALIZER = 'json' 164 | BROKER_URL = 'redis://localhost' 165 | 166 | # Django Bootstrap 167 | BOOTSTRAP3 = { 168 | 'horizontal_label_class': 'col-sm-3 col-lg-2', 169 | 'horizontal_field_class': 'col-sm-9 col-lg-10', 170 | } 171 | 172 | # Debug toolbar 173 | if 'ENABLE_DEBUG_TOOLBAR' in os.environ: 174 | ENABLE_DEBUG_TOOLBAR =bool(os.environ.get('ENABLE_DEBUG_TOOLBAR')) 175 | else: 176 | try: 177 | import debug_toolbar 178 | except ImportError: 179 | ENABLE_DEBUG_TOOLBAR = False 180 | else: 181 | ENABLE_DEBUG_TOOLBAR = True 182 | 183 | if ENABLE_DEBUG_TOOLBAR: 184 | INSTALLED_APPS.append('debug_toolbar') 185 | MIDDLEWARE.insert(0, 'debug_toolbar.middleware.DebugToolbarMiddleware') 186 | 187 | 188 | LOGGING = { 189 | 'version': 1, 190 | 'disable_existing_loggers': False, 191 | 'formatters': { 192 | 'verbose': { 193 | 'format': '%(levelname)s - %(asctime)s - %(name)s: %(message)s' 194 | }, 195 | }, 196 | 'handlers': { 197 | 'console': { 198 | 'class': 'logging.StreamHandler', 199 | 'formatter': 'verbose', 200 | }, 201 | }, 202 | 'loggers': { 203 | '': { 204 | 'handlers': ['console'], 205 | 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'), 206 | }, 207 | }, 208 | } 209 | -------------------------------------------------------------------------------- /swiftwind/accounts/views.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from datetime import timedelta 3 | 4 | from django.contrib.auth.mixins import LoginRequiredMixin 5 | from django.contrib.sites.models import Site 6 | from django.db import models 7 | from django.db.models import Q, Sum, When, Case, Value, Subquery, OuterRef, Exists 8 | from django.db.models.functions import Cast 9 | from django.test import RequestFactory 10 | from django.urls.base import reverse 11 | from django.views import View 12 | from django.views.generic.base import TemplateView 13 | from django.views.generic.detail import DetailView 14 | from django.views.generic.list import ListView 15 | from djmoney.models.fields import MoneyField 16 | 17 | from hordak.models.core import Account, Transaction, Leg 18 | from swiftwind.billing_cycle.models import BillingCycle 19 | from swiftwind.settings.models import Settings 20 | from swiftwind.costs.models import RecurringCostSplit 21 | from swiftwind.housemates.models import Housemate 22 | from swiftwind.utilities.emails import EmailViewMixin 23 | from swiftwind.utilities.site import get_site_root 24 | 25 | 26 | class OverviewView(LoginRequiredMixin, ListView): 27 | template_name = 'accounts/overview.html' 28 | context_object_name = 'accounts' 29 | 30 | def get_queryset(self): 31 | housemate_income = Account.objects.get(name='Housemate Income') 32 | expenses = Account.objects.get(name='Expenses') 33 | current_billing_cycle = BillingCycle.objects.as_of(datetime.date.today()) 34 | 35 | return Account.objects.filter( 36 | 37 | # We want any account under 'Housemate Income' or 'Expenses' 38 | Q(lft__gt=housemate_income.lft, rght__lt=housemate_income.rght, tree_id=housemate_income.tree_id) 39 | | 40 | Q(lft__gt=expenses.lft, rght__lt=expenses.rght, tree_id=expenses.tree_id) 41 | 42 | ).filter( 43 | # We only want leaf accounts (no accounts that contain other accounts) 44 | children__isnull=True 45 | 46 | ).annotate( 47 | # Is this an expense or housemate account? 48 | display_type=Case( 49 | When(housemate__isnull=True, then=Value('expense')), 50 | default=Value('housemate'), 51 | output_field=models.CharField() 52 | ) 53 | 54 | ).annotate( 55 | # When was the last transaction 56 | latest_transaction_date=Subquery( 57 | Transaction.objects.filter(legs__account=OuterRef('pk')).order_by('-date').values('date')[:1] 58 | ) 59 | 60 | ).annotate( 61 | # Has there been a payment during this billing cycle 62 | payment_since_last_bill=Exists( 63 | Transaction.objects.filter( 64 | legs__amount__gt=0, 65 | legs__account=OuterRef('pk'), 66 | date__gte=current_billing_cycle.date_range.lower 67 | ) 68 | ) 69 | 70 | )\ 71 | .order_by('-display_type')\ 72 | .select_related('housemate') 73 | 74 | 75 | class AbstractHousemateStatementView(DetailView): 76 | template_name = 'accounts/housemate_statement.html' 77 | slug_url_kwarg = 'uuid' 78 | slug_field = 'uuid' 79 | context_object_name = 'housemate' 80 | queryset = Housemate.objects.all().select_related('account', 'user') 81 | 82 | def get_context_data(self, **kwargs): 83 | housemate = self.object 84 | date = self.kwargs.get('date') 85 | 86 | billing_cycle = BillingCycle.objects.as_of( 87 | date=datetime.date(*map(int, date.split('-'))) if date else datetime.date.today() 88 | ) 89 | 90 | legs = Leg.objects.filter( 91 | transaction__recurred_cost__billing_cycle=billing_cycle, 92 | account=housemate.account, 93 | ).order_by('-transaction__date', '-pk').select_related( 94 | 'transaction', 95 | 'transaction__recurred_cost__recurring_cost__to_account', 96 | ) 97 | recurring_legs = [l for l in legs if not l.transaction.recurred_cost.recurring_cost.is_one_off()] 98 | one_off_legs = [l for l in legs if l.transaction.recurred_cost.recurring_cost.is_one_off()] 99 | other_legs = Leg.objects.filter( 100 | transaction__date__gte=billing_cycle.date_range.lower, 101 | transaction__date__lt=billing_cycle.date_range.upper, 102 | account=housemate.account, 103 | ).exclude( 104 | pk__in=[l.pk for l in legs] 105 | ).order_by('-transaction__date', '-pk').select_related('transaction') 106 | 107 | recurring_total = sum(l.amount for l in recurring_legs) 108 | one_off_total = sum(l.amount for l in one_off_legs) 109 | 110 | # Previous & next URLs 111 | # Not a pretty way to generate URLs, but parsing the date to reverse the 112 | # historical URL would be pretty onerous. 113 | previous = billing_cycle.get_previous() 114 | next = billing_cycle.get_next() 115 | 116 | if previous: 117 | previous_url = '{}{}/'.format(reverse('accounts:housemate_statement', args=[housemate.uuid]), str(previous.start_date)) 118 | else: 119 | previous_url = '' 120 | 121 | if next and next.start_date <= datetime.date.today(): 122 | next_url = '{}{}/'.format(reverse('accounts:housemate_statement', args=[housemate.uuid]), str(next.start_date)) 123 | else: 124 | next_url = '' 125 | 126 | return super().get_context_data( 127 | billing_cycle=billing_cycle, 128 | start_date=billing_cycle.date_range.lower, 129 | end_date=billing_cycle.date_range.upper - timedelta(days=1), 130 | recurring_legs=recurring_legs, 131 | one_off_legs=one_off_legs, 132 | other_legs=other_legs, 133 | recurring_total=recurring_total, 134 | one_off_total=one_off_total, 135 | total=recurring_total + one_off_total, 136 | payment_history=housemate.account.legs.all().order_by('-transaction__date', '-transaction__pk'), 137 | payment_information=Settings.objects.get().payment_information, 138 | next_url=next_url, 139 | previous_url=previous_url, 140 | **kwargs 141 | ) 142 | 143 | 144 | class HousemateStatementView(LoginRequiredMixin, AbstractHousemateStatementView): 145 | pass 146 | 147 | 148 | class StatementEmailView(EmailViewMixin, AbstractHousemateStatementView): 149 | template_name = 'accounts/statement_email.html' 150 | 151 | 152 | class ReconciliationRequiredEmailView(EmailViewMixin, TemplateView): 153 | template_name = 'accounts/reconciliation_required_email.html' 154 | --------------------------------------------------------------------------------