Commit 65a4fa72 authored by David Seaward's avatar David Seaward Committed by Gogs

Merge branch 'monitor_sales_24' of david.seaward/account_web into master

parents 5853a933 f98eac2e
# Site-specific ignores
purist_account/settings_local.py
# Created by .ignore support plugin (hsz.mobi)
### Project-specific
docs/api/
*.pid
### Basic template
# IntelliJ project files
.idea
*.iml
out
gen
### Python template
# Byte-compiled / optimized / DLL files
__pycache__/
......@@ -28,9 +41,6 @@ wheels/
*.egg-info/
.installed.cfg
*.egg
bin/
lib/
include/
# PyInstaller
# Usually these files are written by a python script from a template
......@@ -60,9 +70,6 @@ coverage.xml
# Django stuff:
*.log
local_settings.py
*.sqlite3
__pycache__
.buildconfig
# Flask stuff:
instance/
......@@ -97,7 +104,24 @@ celerybeat-schedule
venv/
ENV/
# Project settings
.idea
.ropeproject
# Spyder project settings
.spyderproject
# Rope project settings
.ropeproject
### Linux template
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
......@@ -12,18 +12,29 @@ Prerequisites
* Debian 8
* Python 3.4 or 3.5
* Django 1.11 (included in Python packages below)
* Nginx
* RabbitMQ server
* Accessible at `amqp://guest:guest@localhost:5672//`
* This can be achieved with just `apt install rabbitmq-server`
* Additional dependency packages:
* `libsasl2-dev`
* `libldap2-dev`
* `libssl-dev`
* `python3-dev`
* `supervisor`
* Additional uWSGI packages:
* `uwsgi`
* `uwsgi-emperor`
* `uwsgi-plugin-python3`
* Python/Django packages: see `requires/requirements.txt`
* Includes Django 1.11
* External resources:
* LDAP database
* WooCommerce instance (REST API)
* SSH access to an OpenVPN server with `create_new_ovpn_config`
* The Nginx user (`www-data`) needs SSH access to the server
* Test with `sudo -u www-data ssh -p PORT REMOTE_USER@HOSTNAME`
* The user needing access can be changed in `purist_account_monitor.conf`
Other versions and alternatives may work but are untested.
......@@ -71,14 +82,21 @@ Setup
* Hook up uWSGI:
* `sudo apt install uwsgi uwsgi-emperor uwsgi-plugin-python3`
* `cp ./conf/uwsgi_emperor_vassals/purist_account.ini /etc/uwsgi-emperor/vassals/`
* Hook up Supervisor (supervisord):
* `sudo apt install supervisor`
* `cp ./conf/supervisord/purist_account_monitor.conf /etc/supervisor/conf.d/`
* Restart services:
* `sudo service rabbitmq-server restart`
* `sudo service uwsgi-emperor restart`
* `sudo service nginx restart`
* `sudo service supervisor restart`
* Check logs:
* `/var/log/uwsgi/emperor.log`
* `/var/log/uwsgi/app/purist_account.log`
* `/var/log/nginx/error.log`
* `/var/log/nginx/access.log`
* `/var/log/supervisor/supervisord.log`
* `/var/log/purist/account/beat.log`
For more options and details see
<https://docs.djangoproject.com/en/1.11/#the-development-process>
......@@ -140,3 +158,5 @@ Also includes code portions from:
(Copyright 2015 Anders Hofstee and contributors, Expat/MIT)
* https://github.com/asyd/pyldap_orm/blob/master/pyldap_orm/controls.py
(Copyright 2016 Bruno Bonfils, Apache 2.0)
* https://github.com/celery/celery/blob/master/extra/supervisord/celerybeat.conf
(Copyright 2009-2012, 2015-2016 Ask Solem and contributors, 2012-2014 GoPivotal, Inc, BSD 3-Clause)
......@@ -2,19 +2,29 @@
# note that % must be escaped as %%
[settings]
SITE_TITLE=Title
SITE_BYLINE=Example byline
DEBUG=True
ALLOWED_HOSTS=localhost
SITE_TITLE = Title
SITE_BYLINE = Example byline
SITE_DOMAIN = example.com
DEBUG = True
ALLOWED_HOSTS = localhost
STATIC_ROOT = /var/opt/purist/account/static
REGISTRATION_OPEN=True
REG_PERSON_BASE_DN=ou=people,dc=example,dc=com
REG_PERSON_OBJECT_CLASSES=inetOrgPerson,organizationalPerson,person
REG_GROUP_BASE_DN=dc=comms,dc=example,dc=com
REG_GROUP_OBJECT_CLASSES=groupOfNames
AUTH_LDAP_SERVER_URI=ldap://ldap.example.com
AUTH_LDAP_START_TLS=True
AUTH_LDAP_BIND_DN=cn=admin,dc=example,dc=com
AUTH_LDAP_USER_SEARCH_BASE_DN=ou=people,dc=example,dc=com
REGISTRATION_OPEN = True
REG_PERSON_BASE_DN = ou=people,dc=example,dc=com
REG_PERSON_OBJECT_CLASSES = inetOrgPerson,organizationalPerson,person
REG_GROUP_BASE_DN = dc=comms,dc=example,dc=com
REG_GROUP_OBJECT_CLASSES = groupOfNames
AUTH_LDAP_SERVER_URI = ldap://ldap.example.com
AUTH_LDAP_START_TLS = True
AUTH_LDAP_BIND_DN = cn=admin,dc=example,dc=com
AUTH_LDAP_USER_SEARCH_BASE_DN = ou=people,dc=example,dc=com
SQLITE_DB_PATH = /var/opt/purist/account/db.sqlite3
STATICFILES_DIRS = /var/opt/purist/brand
STATICFILES_DIRS = /var/opt/purist/brand,/var/opt/purist/downloads
WOO_URL = https://example.com
WOO_WP_API = True
WOO_VERSION = wc/v1
WOO_PRODUCT_LIST = 123,124
WOO1_FIELD_LIST = Existing username,Username
OVPN_HOSTNAME = ssh.example.com
OVPN_PORT = 22
OVPN_USERNAME = username
OVPN_FILEPATH = "/path/to/{IDENTITY}/{IDENTITY}.ovpn"
......@@ -4,3 +4,5 @@
[settings]
DJANGO_SECRET_KEY=random_key
AUTH_LDAP_BIND_PASSWORD=ldap_password
WOO_CONSUMER_KEY = woo_key
WOO_CONSUMER_SECRET = woo_secret
; stored as /etc/supervisor/conf.d/purist_account_monitor.conf
; Copyright 2017 Purism SPC and contributors
; SPDX-License-Identifier: GPL-3.0+
; Adapted from extra/supervisord/celerybeat.conf in Celery
; Copyright 2012-2014 GoPivotal, Inc.
; Copyright 2009-2012, 2015-2016 Ask Solem and contributors
; https://github.com/celery/celery/blob/master/extra/supervisord/celerybeat.conf
; SPDX-License-Identifier: BSD-3-Clause
[program:purist_account_monitor]
command=/opt/purist/account_virtualenv/bin/celery worker -B --app purist_account --scheduler django_celery_beat.schedulers:DatabaseScheduler --loglevel=INFO
directory=/opt/purist/account
user=www-data
numprocs=1
stdout_logfile=/var/log/purist/account/beat.log
stderr_logfile=/var/log/purist/account/beat.log
autostart=true
autorestart=true
startsecs=10
; if rabbitmq is supervised, set its priority higher
; so it starts first
priority=999
......@@ -15,4 +15,3 @@ threads = 1
plugins = python3,logfile
logger = file:/var/log/uwsgi/app/purist_account.log
vacuum = true
......@@ -28,10 +28,3 @@ class LdapPersonAdmin(admin.ModelAdmin):
admin.site.register(User, UserAdmin)
admin.site.register(LdapGroup, LdapGroupAdmin)
admin.site.register(LdapPerson, LdapPersonAdmin)
#
# Set admin titles
#
admin.site.site_title = "Site administration"
admin.site.site_header = "Site administration"
<!DOCTYPE html>
<!--
Copyright 2017 Purism SPC and contributors
https://plan.puri.st/app/account_web
SPDX-License-Identifier: CC-BY-SA-4.0
-->
<html lang="en">
<head>
<link rel="stylesheet" href="{% static 'PuristFlex.css' %}"/>
<title>{{ site_title }} - {{ site_byline }}</title>
<link rel="icon" sizes="960x960" href="{% static 'favicon.png' %}">
<meta name="application-name" content="{{ site_title }}">
<meta charset="UTF-8">
</head>
<body style="text-align: center">
<p style="text-align: right">
{% if request.user.is_authenticated %}
{% trans "Your are logged in as" %} {{ username }}<br/>
<a href="{% url 'auth_password_change' %}">{% trans "Change password" %}</a> |
{% trans "Logged in as" %} {{ username }}<br/>
<a href="{% url 'profile' %}">{% trans "Profile" %}</a> |
{% if request.user.is_superuser %}
<a href="{% url 'admin:index' %}">{% trans "Admin" %}</a> |
......@@ -22,7 +30,7 @@
{% else %}
{% trans "You are not logged in." %}<br/>
<a href="{% url 'auth_login' %}">{% trans "Log in" %}</a> {% trans "or" %}
<a href="{% url 'registration_register' %}">{% trans "register" %}</a>
<a href="{% url 'registration_register' %}">{% trans "register." %}</a>
{% endif %}
</p>
......
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as BaseUserManager
from ldapdb.models.fields import CharField, ListField
import logging
import ldap
import ldapdb.models
import logging
import pyasn1.type.univ
import pyasn1.type.namedtype
import pyasn1.codec.ber.encoder
import pyasn1.type.namedtype
import pyasn1.type.univ
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as BaseUserManager
from django.core.exceptions import ValidationError
from django.db import connections, router
from django.utils.crypto import salted_hmac
from django.utils.translation import ugettext as _
from ldapdb.models.fields import CharField, ListField
from limitmonitor import models as limitmonitor_models
log = logging.getLogger(__name__)
class WordCountValidator(object):
def __init__(self, min_words=3):
self.min_words = min_words
def validate(self, password, user=None):
is_valid_count = password.count(' ') > (self.min_words - 1)
if not is_valid_count:
raise ValidationError(self.get_help_text())
def get_help_text(self):
return _('Passphrase must contain at least three words. Use spaces between words.')
class UserManager(BaseUserManager):
def create_user(self, username, email=None, password=None, **extra_fields):
"""Create regular users in LDAP, and with no Django password."""
......@@ -35,6 +53,11 @@ class User(AbstractUser):
user = super(User, self).__init__(*args, **kwargs)
return user
@classmethod
def normalize_username(cls, username):
username = super(User, cls).normalize_username(username)
return username.lower()
def validate_unique(self, exclude=None):
return super(User, self).validate_unique(exclude)
......@@ -54,6 +77,11 @@ class User(AbstractUser):
ldap_person = self.get_ldap()
ldap_person.change_password(raw_password)
def get_identity(self):
# FIXME: this doesn't belong in ldapregister
# associated with https://code.puri.sm/purist/account_web/issues/25
return self.get_username() + "@" + settings.SITE_DOMAIN
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
# save django user
......@@ -66,6 +94,11 @@ class User(AbstractUser):
# force null password (will use LDAP password)
self.set_unusable_password()
# create any missing limits
# FIXME: this make ldapregister dependant on limitmonitor
# https://code.puri.sm/purist/account_web/issues/25
limitmonitor_models.create_missing_user_limits(self)
def set_password(self, raw_password):
# force null password (will use LDAP password)
......
<!DOCTYPE html>
{% load i18n %} {% load static %}
<!-- Content, copyright 2017 Purism SPC and contributors -->
<!-- https://plan.puri.st/app/account_web -->
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
<!-- Original registration template, copyright 2015 Anders Hofstee and contributors -->
<!-- https://github.com/RatanShreshtha/django-registration-templates -->
<!-- SPDX-License-Identifier: MIT -->
<!--
Page content, copyright 2017 Purism SPC and contributors
https://plan.puri.st/app/account_web
SPDX-License-Identifier: CC-BY-SA-4.0
Original registration template, copyright 2015 Anders Hofstee and contributors
https://github.com/RatanShreshtha/django-registration-templates
SPDX-License-Identifier: MIT
-->
<html lang="en">
<head>
......@@ -14,13 +17,14 @@
<title>{% block title %}Base{% endblock %}</title>
<link rel="icon" sizes="960x960" href="{% static 'favicon.png' %}">
<meta name="application-name" content="Purist">
<meta charset="UTF-8">
</head>
<body>
<header style="justify-content: space-between;">
<div id="title_box">
<a href="{% url 'home_page' %}"><img class="logo" src="{% static 'logo.png' %}" alt="Purist"/></a>
<a href="{% url 'home' %}"><img class="logo" src="{% static 'logo.png' %}" alt="Purist"/></a>
<div id="title_text">
<h1>{% block header %}Base{% endblock %}</h1>
<p>{% block byline %}Byline{% endblock %}</p>
......@@ -29,7 +33,7 @@
<div id="log_state">
{% if user.is_authenticated %}
{% trans "Logged in as" %} {{ user.username }}<br/>
<a href="{% url 'auth_password_change' %}">{% trans "Change password" %}</a> |
<a href="{% url 'profile' %}">{% trans "Profile" %}</a> |
{% if user.is_superuser %}
<a href="{% url 'admin:index' %}">{% trans "Admin" %}</a> |
{% endif %}
......
from django.conf import settings
from django.shortcuts import render
from purist_account import settings
def home_page(request):
def home(request):
render_data = {
"username": request.user.get_username(),
......
default_app_config = 'limitmonitor.apps.LimitMonitorConfig'
from django.contrib import admin
from .models import Credit, ExternalBundle, ExternalCredit, Limit
#
# Declare admin models
#
class CreditAdmin(admin.ModelAdmin):
list_display = ['limit', 'external', 'time_credit', 'volume_credit']
class ExternalBundleAdmin(admin.ModelAdmin):
list_display = ['parser', 'external_key', 'service', 'time_credit', 'volume_credit']
class ExternalCreditAdmin(admin.ModelAdmin):
list_display = ['parser', 'external_key', 'bundle_key', 'account_name', 'is_converted', 'error_message']
class LimitAdmin(admin.ModelAdmin):
list_display = ['user', 'service', 'is_active', 'expiry_date', 'volume_total', 'time_total']
#
# Register admin models
#
admin.site.register(Credit, CreditAdmin)
admin.site.register(ExternalBundle, ExternalBundleAdmin)
admin.site.register(ExternalCredit, ExternalCreditAdmin)
admin.site.register(Limit, LimitAdmin)
from django.apps import AppConfig
class LimitMonitorConfig(AppConfig):
name = 'limitmonitor'
verbose_name = 'Limit monitor'
<!DOCTYPE html>
<!--
Copyright 2017 Purism SPC and contributors
https://plan.puri.st/app/account_web
SPDX-License-Identifier: CC-BY-SA-4.0
-->
<html lang="en">
<head>
<link rel="stylesheet" href="{% static 'PuristFlex.css' %}"/>
<title>{% trans "User profile" %}</title>
<link rel="icon" sizes="960x960" href="{% static 'favicon.png' %}">
<meta name="application-name" content="{{ site_title }}">
<meta charset="UTF-8">
</head>
<body>
<header style="justify-content: space-between;">
<div id="title_box">
<a href="{% url 'home' %}"><img class="logo" src="{% static 'logo.png' %}" alt="Purist"/></a>
<div id="title_text">
<h1>{% trans "User profile" %}</h1>
<p>{% trans "Service credit and profile management" %}</p>
</div>
</div>
<div id="log_state">
{% if request.user.is_authenticated %}
{% trans "Logged in as" %} {{ username }}<br/>
<em>{% trans "Profile" %}</em> |
{% if request.user.is_superuser %}
<a href="{% url 'admin:index' %}">{% trans "Admin" %}</a> |
{% endif %}
<a href="{% url 'auth_logout' %}">{% trans "Log out" %}</a>
{% else %}
{% trans "You are not logged in." %}<br/>
<a href="{% url 'auth_login' %}">{% trans "Log in" %}</a> {% trans "or" %}
<a href="{% url 'registration_register' %}">{% trans "register." %}</a>
{% endif %}
</div>
</header>
<div id="main">
<article>
<h2>{% trans "Services" %}</h2>
<table style="border-collapse: collapse;">
<thead>
<tr>
<th>{% trans "Service" %}</th>
<th>{% trans "Active" %}</th>
<th>{% trans "Expires" %}</th>
<th>{% trans "Credit" %}</th>
</tr>
</thead>
<tbody>
{% for limit in limits %}
<tr>
<th style="text-align: left;">{{ limit.service_label() }}</th>
<td>{{ limit.is_active }}</td>
<td title="{{ limit.expiry_full_label() }}">{{ limit.expiry_short_label() }}</td>
<td>{{ limit.credit_label() }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h2>{% trans "Profile management" %}</h2>
<ul>
<li><a href="{% url 'auth_password_change' %}">{% trans "Change password" %}</a></li>
</ul>
<h2>{% trans "Downloads" %}</h2>
<ul>
<li><a href="{% url 'ovpn_userfile' %}">{% trans "Tunnel certificate" %}</a></li>
<li><a href="{% static 'tunnel_instructions.pdf' %}">{% trans "How to use Tunnel certificate (PDF)" %}</a>
</li>
</ul>
</article>
</div>
</body>
</html>
# -*- coding: utf-8 -*-
# Generated by Django 1.11.3 on 2017-07-19 14:16
from __future__ import unicode_literals
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Credit',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('time_credit', models.DecimalField(decimal_places=2, default=0, max_digits=6)),
('volume_credit', models.DecimalField(decimal_places=2, default=0, max_digits=6)),
('old_expiry_date', models.DateTimeField(default=None, blank=True, null=True)),
('old_time_total',
models.DecimalField(blank=True, decimal_places=2, default=None, max_digits=6, null=True)),
('old_volume_total',
models.DecimalField(blank=True, decimal_places=2, default=None, max_digits=6, null=True)),
('new_expiry_date', models.DateTimeField(blank=True, default=None, null=True)),
('new_time_total',
models.DecimalField(blank=True, decimal_places=2, default=None, max_digits=6, null=True)),
('new_volume_total',
models.DecimalField(blank=True, decimal_places=2, default=None, max_digits=6, null=True)),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('updated_date', models.DateTimeField(default=django.utils.timezone.now)),
],
),
migrations.CreateModel(
name='ExternalBundle',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('parser', models.CharField(choices=[('WOO1', 'WooCommerce v1')], max_length=30)),
('external_key', models.CharField(max_length=30)),
('service',
models.CharField(choices=[('TUNNEL', 'Tunnel'), ('COMMUNICATION', 'Communication')], max_length=30)),
('time_credit', models.DecimalField(decimal_places=2, default=0, max_digits=6)),
('volume_credit', models.DecimalField(decimal_places=2, default=0, max_digits=6)),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('updated_date', models.DateTimeField(default=django.utils.timezone.now)),
],
),
migrations.CreateModel(
name='ExternalCredit',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('parser', models.CharField(choices=[('WOO1', 'WooCommerce v1')], max_length=30)),
('external_key', models.CharField(max_length=30)),
('label', models.CharField(max_length=30)),
('bundle_key', models.CharField(max_length=30)),
('bundle_label', models.CharField(max_length=30)),
('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=6)),
('account_name', models.CharField(default='', max_length=30)),
('additional_data', models.TextField(default='')),
('is_converted', models.BooleanField(default=False)),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('updated_date', models.DateTimeField(default=django.utils.timezone.now)),
],
),
migrations.CreateModel(
name='Limit',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('service',
models.CharField(choices=[('TUNNEL', 'Tunnel'), ('COMMUNICATION', 'Communication')], max_length=30)),
('expiry_date', models.DateTimeField(blank=True, default=None, null=True)),
('volume_total', models.DecimalField(decimal_places=2, max_digits=6)),
('time_total', models.DecimalField(decimal_places=2, max_digits=6)),
('is_active', models.BooleanField()),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('updated_date', models.DateTimeField(default=django.utils.timezone.now)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='credit',
name='external',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE,
to='limitmonitor.ExternalCredit'),
),
migrations.AddField(
model_name='credit',
name='limit',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='limitmonitor.Limit'),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.3 on 2017-08-15 11:59
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('limitmonitor', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='externalcredit',
name='error_message',
field=models.TextField(default=''),
),
migrations.AlterField(
model_name='limit',
name='is_active',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='limit',
name='time_total',
field=models.DecimalField(decimal_places=2, default=0, max_digits=6),
),
migrations.AlterField(
model_name='limit',
name='volume_total',
field=models.DecimalField(decimal_places=2, default=0, max_digits=6),
),
]
from django.conf import settings
from django.db import models
from django.utils import timezone
EXTERNAL_PARSER_CHOICES = (
("WOO1", "WooCommerce v1"),
)
SERVICE_CHOICES = (
("TUNNEL", "Tunnel"),
("COMMUNICATION", "Communication"),
)
def create_missing_user_limits(user):
for code, label in SERVICE_CHOICES:
if not Limit.objects.filter(user=user, service=code).exists():
Limit(user=user, service=code).save()
class Limit(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL)
service = models.CharField(max_length=30, choices=SERVICE_CHOICES)
expiry_date = models.DateTimeField(default=None, blank=True, null=True)
volume_total = models.DecimalField(default=0, decimal_places=2, max_digits=6)
time_total = models.DecimalField(default=0, decimal_places=2, max_digits=6)
is_active = models.BooleanField(default=False)
created_date = models.DateTimeField(default=timezone.now)
updated_date = models.DateTimeField(default=timezone.now)
def service_label(self):
label = "None"
for key, value in SERVICE_CHOICES:
if self.service == key:
label = value
return label
def expiry_short_label(self):
if self.expiry_date is None:
return "-"
else:
return self.expiry_date.strftime("%Y-%m-%d")
def expiry_full_label(self):
if self.expiry_date is None:
return "-"
else:
return self.expiry_date.strftime("%Y-%m-%d %H:%M:%S %z")
def credit_label(self):
if self.service == "TUNNEL":