Adding accounting and membership apps + pre-commit linting stuff

This commit is contained in:
Benjamin Bach 2018-06-23 21:08:56 +02:00
parent f46b031a8d
commit a8321decde
23 changed files with 625 additions and 28 deletions

View File

@ -12,17 +12,3 @@ repos:
rev: v1.0.1 rev: v1.0.1
hooks: hooks:
- id: reorder-python-imports - id: reorder-python-imports
- repo: local
hooks:
- id: frontend-prettify
name: Prettier Linting of JS and Vue files
description: This hook rewrites JS and the JS portion of Vue files to conform to prettier standards.
entry: yarn run lint-js -- -w
language: system
files: \.(js|vue)$
- id: frontend-eslint
name: ESLinting of JS and Vue files
description: This hook rewrites JS and the JS portion of Vue files to conform to our ESLint standards.
entry: node node_modules/eslint/bin/eslint.js
language: system
files: \.(js|vue)$

15
Makefile Normal file
View File

@ -0,0 +1,15 @@
# These are just some make targets, expressing how things
# are supposed to be run, but feel free to change them!
dev-setup:
pip install -r requirements_dev.txt --upgrade
pip install -r requirements.txt --upgrade
pre-commit install
python manage.py migrate
python manage.py createsuperuser
lint:
pre-commit run --all
test:
pytest

11
README.rst Normal file
View File

@ -0,0 +1,11 @@
member.data.coop
================
To start developing:
# Create a virtualenv with python 3
$ mkvirtualenv -p python3 datacoop
# Run this make target, which installs all the requirements and sets up a
# development database.
$ make dev-setup

0
accounting/__init__.py Normal file
View File

28
accounting/admin.py Normal file
View File

@ -0,0 +1,28 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from . import models
@admin.register(models.Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ('who', 'description', 'created', 'is_paid',)
def who(self, instance):
return instance.user.get_full_name()
who.short_description = _("Customer")
@admin.register(models.Payment)
class PaymentAdmin(admin.ModelAdmin):
list_display = ('who', 'description', 'order_id', 'created',)
def who(self, instance):
return instance.order.user.get_full_name()
who.short_description = _("Customer")
def order_id(self, instance):
return instance.order.id
order_id.short_description = _("Order ID")

5
accounting/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class AccountingConfig(AppConfig):
name = 'accounting'

View File

@ -0,0 +1,84 @@
# Generated by Django 2.0.6 on 2018-06-23 19:51
from decimal import Decimal
import django.db.models.deletion
import djmoney.models.fields
from django.conf import settings
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Account',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Order',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('description', models.CharField(max_length=1024, verbose_name='description')),
('price_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default='XYZ', editable=False, max_length=3)),
('price', djmoney.models.fields.MoneyField(decimal_places=2, default=Decimal('0.0'), max_digits=16, verbose_name='price (excl. VAT)')),
('vat_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default='XYZ', editable=False, max_length=3)),
('vat', djmoney.models.fields.MoneyField(decimal_places=2, default=Decimal('0.0'), max_digits=16, verbose_name='VAT')),
('is_paid', models.BooleanField(default=False, verbose_name='is paid')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='accounting.Account')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Order',
'verbose_name_plural': 'Orders',
},
),
migrations.CreateModel(
name='Payment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('amount_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default='XYZ', editable=False, max_length=3)),
('amount', djmoney.models.fields.MoneyField(decimal_places=2, default=Decimal('0.0'), max_digits=16)),
('description', models.CharField(max_length=1024, verbose_name='description')),
('stripe_charge_id', models.CharField(blank=True, max_length=255, null=True)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='accounting.Order')),
],
options={
'verbose_name': 'payment',
'verbose_name_plural': 'payments',
},
),
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('amount_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default='XYZ', editable=False, max_length=3)),
('amount', djmoney.models.fields.MoneyField(decimal_places=2, default=Decimal('0.0'), help_text='This will include VAT', max_digits=16, verbose_name='amount')),
('description', models.CharField(max_length=1024, verbose_name='description')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='accounting.Account')),
],
options={
'abstract': False,
},
),
]

View File

156
accounting/models.py Normal file
View File

@ -0,0 +1,156 @@
from hashlib import md5
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.db.models.aggregates import Sum
from django.utils.translation import gettext as _
from django.utils.translation import pgettext_lazy
from djmoney.models.fields import MoneyField
class CreatedModifiedAbstract(models.Model):
modified = models.DateTimeField(
auto_now=True,
verbose_name=_("modified"),
)
created = models.DateTimeField(
auto_now_add=True,
verbose_name=_("created"),
)
class Meta:
abstract = True
class Account(CreatedModifiedAbstract):
"""
This is the model where we can give access to several users, such that they
can decide which account to use to pay for something.
"""
owner = models.ForeignKey(get_user_model(), on_delete=models.PROTECT)
def balance(self):
return self.transactions.all().aggregate(Sum('amount')).get('amount', 0)
class Transaction(CreatedModifiedAbstract):
"""
Tracks in and outgoing events of an account. When an order is received, an
amount is subtracted, when a payment is received, an amount is added.
"""
account = models.ForeignKey(
Account,
on_delete=models.PROTECT,
related_name="transactions",
)
amount = MoneyField(
verbose_name=_("amount"),
max_digits=16,
decimal_places=2,
help_text=_("This will include VAT")
)
description = models.CharField(
max_length=1024,
verbose_name=_("description")
)
class Order(CreatedModifiedAbstract):
"""
Scoped out: Contents of invoices will have to be tracked either here or in
a separate Invoice model. This is undecided because we are not generating
invoices at the moment.
"""
user = models.ForeignKey(get_user_model(), on_delete=models.PROTECT)
account = models.ForeignKey(Account, on_delete=models.PROTECT)
is_paid = models.BooleanField(default=False)
description = models.CharField(
max_length=1024,
verbose_name=_("description")
)
price = MoneyField(
verbose_name=_("price (excl. VAT)"),
max_digits=16,
decimal_places=2,
)
vat = MoneyField(
verbose_name=_("VAT"),
max_digits=16,
decimal_places=2,
)
is_paid = models.BooleanField(
default=False,
verbose_name=_("is paid"),
)
@property
def total(self):
return self.price + self.vat
@property
def display_id(self):
return str(self.id).zfill(6)
@property
def payment_token(self):
pk = str(self.pk).encode("utf-8")
x = md5()
x.update(pk)
extra_hash = (settings.SECRET_KEY + 'blah').encode('utf-8')
x.update(extra_hash)
return x.hexdigest()
class Meta:
verbose_name = pgettext_lazy("accounting term", "Order")
verbose_name_plural = pgettext_lazy("accounting term", "Orders")
def __str__(self):
return "Order ID {id}".format(id=self.display_id)
class Payment(CreatedModifiedAbstract):
amount = MoneyField(
max_digits=16,
decimal_places=2,
)
order = models.ForeignKey(Order, on_delete=models.PROTECT)
description = models.CharField(
max_length=1024,
verbose_name=_("description")
)
stripe_charge_id = models.CharField(
max_length=255,
null=True,
blank=True,
)
@property
def display_id(self):
return str(self.id).zfill(6)
@classmethod
def from_order(cls, order):
return cls.objects.create(
order=order,
user=order.user,
amount=order.total,
description=order.description,
)
def __str__(self):
return "Payment ID {id}".format(id=self.display_id)
class Meta:
verbose_name = _("payment")
verbose_name_plural = _("payments")

0
accounting/test.py Normal file
View File

8
membership/__init__.py Normal file
View File

@ -0,0 +1,8 @@
"""
Membership application
======================
This application's domain relate to organizational structures and
implementation of statutes, policies etc.
"""

8
membership/admin.py Normal file
View File

@ -0,0 +1,8 @@
from django.contrib import admin
from . import models
@admin.register(models.Membership)
class MembershipAdmin(admin.ModelAdmin):
pass

5
membership/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class MembershipConfig(AppConfig):
name = 'membership'

View File

@ -0,0 +1,101 @@
# Generated by Django 2.0.6 on 2018-06-23 19:07
from decimal import Decimal
import django.db.models.deletion
import djmoney.models.fields
from django.conf import settings
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Membership',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('can_vote', models.BooleanField(default=False, help_text='Indicates that the user has a democratic membership of the organization.', verbose_name='can vote')),
],
options={
'verbose_name': 'membership',
'verbose_name_plural': 'memberships',
},
),
migrations.CreateModel(
name='Organization',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('name', models.CharField(max_length=64, verbose_name='name')),
],
options={
'verbose_name': 'organization',
'verbose_name_plural': 'organizations',
},
),
migrations.CreateModel(
name='Subscription',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('active', models.BooleanField(default=False, help_text='Automatically set by payment system.', verbose_name='active')),
('starts', models.DateField()),
('ends', models.DateField()),
('renewed_subscription', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='membership.Subscription', verbose_name='renewed subscription')),
],
options={
'verbose_name': 'subscription',
'verbose_name_plural': 'subscriptions',
},
),
migrations.CreateModel(
name='SubscriptionType',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('name', models.CharField(max_length=64, verbose_name='name')),
('fee_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default='XYZ', editable=False, max_length=3)),
('fee', djmoney.models.fields.MoneyField(decimal_places=2, default=Decimal('0.0'), max_digits=16)),
('fee_vat_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default='XYZ', editable=False, max_length=3)),
('fee_vat', djmoney.models.fields.MoneyField(decimal_places=2, default=Decimal('0'), max_digits=16)),
('duration', models.PositiveSmallIntegerField(choices=[(1, 'annual')], default=1, verbose_name='duration')),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='membership.Organization')),
],
options={
'verbose_name': 'subscription type',
'verbose_name_plural': 'subscription types',
},
),
migrations.AddField(
model_name='subscription',
name='subscription_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='membership.SubscriptionType', verbose_name='subscription type'),
),
migrations.AddField(
model_name='subscription',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='membership',
name='organization',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='membership.Organization'),
),
migrations.AddField(
model_name='membership',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
]

View File

144
membership/models.py Normal file
View File

@ -0,0 +1,144 @@
from django.contrib.auth import get_user_model
from django.db import models
from django.utils.translation import gettext as _
from djmoney.models.fields import MoneyField
class CreatedModifiedAbstract(models.Model):
modified = models.DateTimeField(
auto_now=True,
verbose_name=_("modified"),
)
created = models.DateTimeField(
auto_now_add=True,
verbose_name=_("created"),
)
class Meta:
abstract = True
class Organization(CreatedModifiedAbstract):
"""
This holds the data of the organization that someone is a member of. It is
possible that we'll create more advanced features here.
"""
name = models.CharField(
verbose_name=_("name"),
max_length=64,
)
def __str__(self):
return self.name
class Meta:
verbose_name = _("organization")
verbose_name_plural = _("organizations")
class Membership(CreatedModifiedAbstract):
"""
A user remains a member of an organization even though the subscription is
unpaid or renewed. This just changes the status/permissions etc. of the
membership, thus we need to track subscription creation, expiry, renewals
etc. and ensure that the membership is modified accordingly.
This expresses some
"""
organization = models.ForeignKey(Organization, on_delete=models.PROTECT)
user = models.ForeignKey(get_user_model(), on_delete=models.PROTECT)
can_vote = models.BooleanField(
default=False,
verbose_name=_("can vote"),
help_text=_(
"Indicates that the user has a democratic membership of the "
"organization."
)
)
def __str__(self):
return _("{} is a member of {}").format(
self.user.get_full_name(),
self.organization.name,
)
class Meta:
verbose_name = _("membership")
verbose_name_plural = _("memberships")
class SubscriptionType(CreatedModifiedAbstract):
"""
Properties of subscriptions are stored here. Should of course not be edited
after subscriptions are created.
"""
organization = models.ForeignKey(Organization, on_delete=models.PROTECT)
name = models.CharField(
verbose_name=_("name"),
max_length=64,
)
fee = MoneyField(
max_digits=16,
decimal_places=2,
)
fee_vat = MoneyField(
max_digits=16,
decimal_places=2,
default=0,
)
duration = models.PositiveSmallIntegerField(
default=1,
choices=[(1, _("annual"))],
verbose_name=_("duration"),
)
class Meta:
verbose_name = _("subscription type")
verbose_name_plural = _("subscription types")
class Subscription(CreatedModifiedAbstract):
"""
To not confuse other types of subscriptions, one can be a *subscribed*
member of an organization, meaning that they are paying etc.
A subscription does not track payment, this is done in the accounting app.
"""
subscription_type = models.ForeignKey(
SubscriptionType,
related_name='memberships',
verbose_name=_("subscription type"),
on_delete=models.PROTECT,
)
user = models.ForeignKey(get_user_model(), on_delete=models.PROTECT)
active = models.BooleanField(
default=False,
verbose_name=_("active"),
help_text=_("Automatically set by payment system.")
)
starts = models.DateField()
ends = models.DateField()
renewed_subscription = models.ForeignKey(
'self',
null=True,
blank=True,
verbose_name=_("renewed subscription"),
on_delete=models.PROTECT,
)
class Meta:
verbose_name = _("subscription")
verbose_name_plural = _("subscriptions")

View File

@ -1,10 +1,14 @@
import warnings import warnings
from .base import * from .base import * # noqa
try: try:
from .local import * from .local import * # noqa
except ImportError: except ImportError:
warnings.warn("No settings.local, using a default SECRET_KEY 'hest'") warnings.warn(
"No settings.local, using a default SECRET_KEY 'hest'. You should "
"write a custom local.py with this setting."
)
SECRET_KEY = "hest" SECRET_KEY = "hest"
pass DEBUG = True
pass

View File

@ -9,7 +9,6 @@ https://docs.djangoproject.com/en/2.0/topics/settings/
For the full list of settings and their values, see For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.0/ref/settings/ https://docs.djangoproject.com/en/2.0/ref/settings/
""" """
import os import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
@ -20,7 +19,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = False
ALLOWED_HOSTS = [] ALLOWED_HOSTS = []
@ -37,6 +36,8 @@ INSTALLED_APPS = [
'django.contrib.sites', 'django.contrib.sites',
'profiles', 'profiles',
'accounting',
'membership',
'allauth', 'allauth',
'allauth.account', 'allauth.account',
@ -94,16 +95,16 @@ DATABASES = {
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # noqa
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # noqa
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # noqa
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # noqa
}, },
] ]
@ -130,3 +131,6 @@ STATIC_URL = '/static/'
SITE_ID = 1 SITE_ID = 1
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
CURRENCIES = ('DKK',)
CURRENCY_CHOICES = [('DKK', 'DKK')]

View File

@ -1,8 +1,8 @@
"""URLs for the membersystem""" """URLs for the membersystem"""
from django.contrib import admin
from django.conf.urls import url from django.conf.urls import url
from django.urls import include, path from django.contrib import admin
from django.urls import include
from django.urls import path
urlpatterns = [ urlpatterns = [
url(r'^accounts/', include('allauth.urls')), url(r'^accounts/', include('allauth.urls')),

View File

@ -6,7 +6,6 @@ It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see For more information on this file, see
https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/ https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/
""" """
import os import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application

View File

@ -0,0 +1,24 @@
# Generated by Django 2.0.6 on 2018-06-23 19:45
import django.db.models.deletion
from django.conf import settings
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Profile',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

2
requirements_dev.txt Normal file
View File

@ -0,0 +1,2 @@
pytest
pre-commit

13
setup.cfg Normal file
View File

@ -0,0 +1,13 @@
[flake8]
ignore = E226,E302,E41
max-line-length = 160
max-complexity = 10
exclude = */migrations/*
[isort]
atomic = true
multi_line_output = 5
line_length = 160
indent = ' '
combine_as_imports = true
skip = wsgi.py,.eggs,setup.py