Commit 7bdc24d8 authored by David Seaward's avatar David Seaward Committed by Gogs

Merge branch 'int_collection_field' of david.seaward/purist_middleware into master

parents 644b07ef 5c8cb10a
......@@ -25,7 +25,6 @@ Prerequisites
* [REST API enabled](https://docs.woocommerce.com/document/woocommerce-rest-api/)
* [WooCommerce Subscriptions](https://woocommerce.com/products/woocommerce-subscriptions/)
* [JWT Authentication for WP REST API](https://wordpress.org/plugins/jwt-authentication-for-wp-rest-api/)
(pending)
* OpenVPN instance (SSH access)
* Including [openvpn-confgen](https://plan.puri.st/module/openvpn_confgen)
* Typically, the Nginx user (`www-data`) will need SSH access
......@@ -88,16 +87,24 @@ Setup
* `sudo service nginx restart`
* `sudo service supervisor restart`
* Check logs:
* `/var/log/uwsgi/emperor.log`
* `/var/log/uwsgi/app/purist_middleware.log`
* `/var/log/nginx/error.log`
* `/var/log/nginx/access.log`
* `/var/log/supervisor/supervisord.log`
* `/var/log/nginx/error.log`
* `/var/log/purist/middleware/beat.log`
* `/var/log/supervisor/supervisord.log`
* `/var/log/uwsgi/emperor.log`
* `/var/log/uwsgi/app/purist_middleware.log`
For more options and details see
<https://docs.djangoproject.com/en/1.11/#the-development-process>
Configure
---------
* Log in to admin interface as superuser
* Define intervals in Django_Celery_Beat > Intervals
* Define periodic tasks in Django_Celery_Beat > Periodic tasks
* Define known products in Limit Monitor > External bundles
Update
------
......
......@@ -11,6 +11,7 @@ DEBUG=True
DEBUG_ALL_ACCESS=True
DEBUG_CHANGE_PASSWORD=False
DEBUG_SKIP_ACTIVATION_COMMAND=True
DEBUG_SKIP_VALIDATE_ON_AUTHENTICATION = False
# change to false after initial setup
ALLOWED_HOSTS=localhost
STATIC_ROOT=/var/opt/purist/middleware/static
......@@ -28,11 +29,10 @@ 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
WOO_QUERY_STRING_AUTH = True
WOOSUB1_PRODUCT_LIST = 123,124
OVPN_HOSTNAME=ssh.example.com
OVPN_PORT=22
OVPN_USERNAME=username
OVPN_FILEPATH="/path/to/{IDENTITY}/{IDENTITY}.ovpn"
SUBSCRIPTION_LINK=https://www.example.com
......@@ -69,7 +69,7 @@ SPDX-License-Identifier: AGPL-3.0
{% for limit in limits %}
<tr>
<th>{{ limit.service_label() }}</th>
<th class="row_header">{{ limit.service_label() }}</th>
<td>{{ limit.active_label() }}</td>
<td title="{{ limit.endpoint_full_label() }}">{{ limit.endpoint_short_label() }}</td>
<td>{{ limit.credit_label() }}</td>
......@@ -80,15 +80,6 @@ SPDX-License-Identifier: AGPL-3.0
</table>
<h2>{% trans "Profile management" %}</h2>
<ul>
{% if DEBUG_CHANGE_PASSWORD %}
<li><a href="{% url 'auth_password_change' %}">{% trans "Change password" %}</a></li>
<li><a href="{{ link_subscription }}" target="_blank">{% trans "Manage subscriptions" %}</a></li>
{% endif %}
</ul>
<h2>{% trans "Downloads" %}</h2>
<ul>
......@@ -103,6 +94,14 @@ SPDX-License-Identifier: AGPL-3.0
{% endif %}
</ul>
{% if DEBUG_CHANGE_PASSWORD %}
<h2>{% trans "Profile management" %}</h2>
<ul>
<li><a href="{% url 'auth_password_change' %}">{% trans "Change password" %}</a></li>
</ul>
{% endif %}
</article>
<nav>
......
......@@ -17,25 +17,25 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='externalbundle',
name='parser',
field=choicesenum.django.fields.EnumIntegerField(default=limitmonitor.models.ExternalParser(0),
enum=limitmonitor.models.ExternalParser),
field=choicesenum.django.fields.EnumIntegerField(default=limitmonitor.models.Legacy(0),
enum=limitmonitor.models.Legacy),
),
migrations.AlterField(
model_name='externalbundle',
name='service',
field=choicesenum.django.fields.EnumIntegerField(default=limitmonitor.models.Service(0),
enum=limitmonitor.models.Service),
field=choicesenum.django.fields.EnumIntegerField(default=limitmonitor.models.Legacy(0),
enum=limitmonitor.models.Legacy),
),
migrations.AlterField(
model_name='externalcredit',
name='parser',
field=choicesenum.django.fields.EnumIntegerField(default=limitmonitor.models.ExternalParser(0),
enum=limitmonitor.models.ExternalParser),
field=choicesenum.django.fields.EnumIntegerField(default=limitmonitor.models.Legacy(0),
enum=limitmonitor.models.Legacy),
),
migrations.AlterField(
model_name='limit',
name='service',
field=choicesenum.django.fields.EnumIntegerField(default=limitmonitor.models.Service(0),
enum=limitmonitor.models.Service),
field=choicesenum.django.fields.EnumIntegerField(default=limitmonitor.models.Legacy(0),
enum=limitmonitor.models.Legacy),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.6 on 2018-01-09 11:39
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('limitmonitor', '0003_auto_20171208_1327'),
]
operations = [
migrations.AlterField(
model_name='externalbundle',
name='service',
field=models.IntegerField(choices=[(0, 'Undefined')], default=0),
),
migrations.AlterField(
model_name='limit',
name='service',
field=models.IntegerField(choices=[(0, 'Undefined')], default=0),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.6 on 2018-01-09 12:11
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('limitmonitor', '0004_auto_20180109_1139'),
]
operations = [
migrations.AlterField(
model_name='externalbundle',
name='parser',
field=models.IntegerField(choices=[(0, 'Undefined')], default=0),
),
migrations.AlterField(
model_name='externalcredit',
name='parser',
field=models.IntegerField(choices=[(0, 'Undefined')], default=0),
),
]
from choicesenum import ChoicesEnum
from choicesenum.django.fields import EnumIntegerField
from django.conf import settings
from django.db import models
from django.utils import timezone
class ExternalParser(ChoicesEnum):
class Legacy(ChoicesEnum):
UNDEFINED = 0, "Undefined"
WOO1 = 1, "WooCommerce v1"
WOOSUB1 = 2, "WooCommerce Subscription v1"
class Service(ChoicesEnum):
UNDEFINED = 0, "Undefined"
TUNNEL = 1, "Tunnel"
COMMUNICATION = 2, "Communication"
def create_missing_user_limits(user):
for code, label in Service.choices():
is_undefined = code == Service.UNDEFINED.value
for code in settings.LM_SERVICES.MAP.keys():
is_defined = code != settings.LM_SERVICES.UNDEFINED
is_exists = Limit.objects.filter(user=user, service=code).exists()
if not is_undefined and not is_exists:
if is_defined and not is_exists:
Limit(user=user, service=code).save()
class Limit(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL)
service = EnumIntegerField(enum=Service, default=Service.UNDEFINED)
service = models.IntegerField(default=settings.LM_SERVICES.UNDEFINED,
choices=settings.LM_SERVICES.choices())
renewal_date = models.DateTimeField(default=None, blank=True, null=True)
expiry_date = models.DateTimeField(default=None, blank=True, null=True)
volume_total = models.DecimalField(default=0, decimal_places=2, max_digits=6)
......@@ -38,12 +30,7 @@ class Limit(models.Model):
def service_label(self):
label = "None"
for key, value in Service.choices():
if self.service == key:
label = value
return label
return settings.LM_SERVICES.get_name_by_code(self.service)
def active_label(self):
......@@ -77,7 +64,7 @@ class Limit(models.Model):
def credit_label(self):
if self.service == Service.TUNNEL:
if self.service == settings.LM_SERVICES.TUNNEL:
return self.days_credit_label()
else:
return self.days_credit_label()
......@@ -118,9 +105,11 @@ class Limit(models.Model):
class ExternalBundle(models.Model):
parser = EnumIntegerField(enum=ExternalParser, default=ExternalParser.UNDEFINED)
parser = models.IntegerField(default=settings.LM_PARSERS.UNDEFINED,
choices=settings.LM_PARSERS.choices())
external_key = models.CharField(max_length=30)
service = EnumIntegerField(enum=Service, default=Service.UNDEFINED)
service = models.IntegerField(default=settings.LM_SERVICES.UNDEFINED,
choices=settings.LM_SERVICES.choices())
time_credit = models.DecimalField(default=0, decimal_places=2, max_digits=6)
volume_credit = models.DecimalField(default=0, decimal_places=2, max_digits=6)
created_date = models.DateTimeField(default=timezone.now)
......@@ -128,7 +117,8 @@ class ExternalBundle(models.Model):
class ExternalCredit(models.Model):
parser = EnumIntegerField(enum=ExternalParser, default=ExternalParser.UNDEFINED)
parser = models.IntegerField(default=settings.LM_PARSERS.UNDEFINED,
choices=settings.LM_PARSERS.choices())
external_key = models.CharField(max_length=30)
label = models.CharField(max_length=30)
bundle_key = models.CharField(max_length=30)
......@@ -141,13 +131,17 @@ class ExternalCredit(models.Model):
created_date = models.DateTimeField(default=timezone.now)
updated_date = models.DateTimeField(default=timezone.now)
@property
def parser_name(self):
return settings.LM_PARSERS.get_name_by_code(self.parser)
@property
def external_code(self):
return str(self.parser) + ":" + str(self.external_key)
return self.parser_name + ":" + str(self.external_key)
@property
def external_bundle(self):
return str(self.parser) + ":" + str(self.external_key)
return self.parser_name + ":" + str(self.external_key)
class Credit(models.Model):
......
......@@ -8,7 +8,7 @@ from django.db import transaction
from django.utils import timezone
from woocommerce import API as WOO_API
from ..models import ExternalCredit, ExternalBundle, Limit, Service
from ..models import ExternalCredit, ExternalBundle, Limit
logger = get_task_logger(__name__)
......@@ -20,9 +20,25 @@ def get_woo_connection():
consumer_secret=settings.WOO_CONSUMER_SECRET,
wp_api=settings.WOO_WP_API,
version=settings.WOO_VERSION,
query_string_auth=settings.WOO_QUERY_STRING_AUTH,
)
def get_username_from_woo_customer_id(customer_id, woo=None):
if woo is None:
woo = get_woo_connection()
try:
query = "customers/" + str(customer_id)
result = woo.get(query).json()
return result["username"] + "@" + settings.SITE_DOMAIN
except Exception as e:
logger.exception("Could not retrieve username for customer_id " + str(customer_id))
return "invalid"
return account
def get_openvpn_ssh_connection():
# make ssh connection to OpenVPN server
# (uses system host keys, warns if host is not recognised)
......@@ -101,7 +117,7 @@ def activate(ssh, limit, credit_timedelta=None, renewal_date=None):
# otherwise, activate the limit before saving
if limit.service == Service.TUNNEL:
if limit.service == settings.LM_SERVICES.TUNNEL:
user_identity = limit.user.get_identity()
filepath = settings.OVPN_FILEPATH.replace("{USER_IDENTITY}", user_identity)
is_file = pathlib.Path(filepath).is_file()
......@@ -117,31 +133,13 @@ def activate(ssh, limit, credit_timedelta=None, renewal_date=None):
def deactivate(ssh, limit):
if limit.service == Service.TUNNEL:
if limit.service == settings.LM_SERVICES.TUNNEL:
managed_exec(ssh, "./create_new_ovpn_config --revoke %s" % (limit.user.get_identity(),))
limit.is_active = False
limit.save()
def get_account_from_woo_meta(meta_list):
account = "invalid"
for meta_item in meta_list:
if meta_item["key"] in settings.WOO1_FIELD_LIST: # for example, "Existing username,"
account = meta_item["value"]
at_count = account.count("@")
if at_count == 0: # nodomain
account += "@" + settings.SITE_DOMAIN # corrected to nodomain@example.com
elif at_count == 1:
pass # valid account format
else: # at_count > 1, for example bad@user@example.com
account = account.replace("@", ".AT.") # force invalid name bad.AT.user.AT.example.com
return account
def get_limit_objects(credit):
# get and validate local username
......@@ -174,22 +172,22 @@ def store_credit_and_update_limit(ssh, credit, next_renewal=None):
if credit.parser == "WOO1":
from .tunnel_credit import update_limit_woo1
update_limit_woo1(ssh, credit)
elif credit.parser == "WOOSUB1":
elif credit.parser == 2: # FIXME: this is WOO_SUBSCRIPTION_V1 from purist.limitmonitor
from .tunnel_subscription import update_limit_woosub1
update_limit_woosub1(ssh, credit, next_renewal)
else:
raise Exception("Unrecognised parser " + credit.parser)
raise Exception("Unrecognised Parser " + str(credit.parser))
credit.is_converted = True
credit.error_message = ""
except Exception as e:
message = "Skipped adding credit " + credit.parser + ":" + credit.external_key + ". "
message = "Skipped adding credit Parser " + str(credit.parser) + ": " + credit.external_key + ". "
logger.exception(message)
credit.error_message = message + repr(e)
finally:
credit.save()
state = "converted" if credit.is_converted else "skipped"
logger.info("Stored " + state + " credit " + credit.parser + ":" + credit.external_key)
logger.info("Stored " + state + " credit Parser " + str(credit.parser) + ": " + credit.external_key)
def deactivate_all_expired_limits():
......
......@@ -17,7 +17,7 @@ def parse_woo1(json_entry, product_id):
product_id = line_item["product_id"]
product_label = line_item["name"]
quantity = line_item["quantity"]
account = get_account_from_woo_meta(line_item["meta"])
account = None # FIXME: need to get the account
external_key = str(order_id) + ":" + str(item_id)
external_label = str(order_name)
......
......@@ -23,16 +23,19 @@ def parse_woosub1(json_entry):
next_renewal_naive = datetime.datetime.strptime(json_entry["next_payment_date"], "%Y-%m-%dT%H:%M:%S")
next_renewal = timezone.make_aware(next_renewal_naive)
# get account name
username = get_username_from_woo_customer_id(json_entry["customer_id"])
# create result
result = {
"parser": "WOOSUB1",
"parser": 2, # FIXME: this is WOO_SUBSCRIPTION_V1 from purist.limitmonitor
"external_key": subscription_id,
"label": json_entry["number"],
"bundle_key": str(line_item["product_id"]),
"bundle_label": str(line_item["name"]),
"quantity": 1,
"account": get_account_from_woo_meta(line_item["meta"]),
"account": username,
"next_renewal": next_renewal,
}
......@@ -52,7 +55,9 @@ def monitor_woosub1_new_subscriptions():
# parse recent subscriptions and store results
for json_entry in latest_subscription_json:
try:
result_list.extend(parse_woosub1(json_entry))
product_id = int(json_entry["line_items"][0]["product_id"])
if product_id in settings.WOOSUB1_PRODUCT_LIST:
result_list.extend(parse_woosub1(json_entry))
except Exception as e:
logger.exception("Skipping JSON entry " + str(json_entry))
......
......@@ -15,7 +15,8 @@ def userlimit(request):
has_limit = {}
none_limit = True
for limit in limits:
has_limit[limit.service] = limit.is_active
label = limit.service_label().upper()
has_limit[label] = limit.is_active
if limit.is_active:
none_limit = False
......@@ -31,7 +32,6 @@ def userlimit(request):
"site_provider_link": settings.SITE_PROVIDER_LINK,
"limits": limits,
"has_limit": has_limit,
"link_subscription": settings.LINK_SUBSCRIPTION,
"link_profile_ordered_dict": settings.LINK_PROFILE_ORDERED_DICT,
}
......
......@@ -3,6 +3,7 @@ import strictyaml
from decouple import Config, Csv, RepositoryIni
from django_auth_ldap.config import LDAPSearch
import purist.limitmonitor
from .settings_original import *
#
......@@ -28,9 +29,12 @@ SECRET_KEY = secret_config("DJANGO_SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = config("DEBUG", cast=bool)
# it is safe to use these flags in production
DEBUG_ALL_ACCESS = config("DEBUG_ALL_ACCESS", cast=bool)
DEBUG_CHANGE_PASSWORD = config("DEBUG_CHANGE_PASSWORD", cast=bool)
DEBUG_SKIP_ACTIVATION_COMMAND = config("DEBUG_SKIP_ACTIVATION_COMMAND", cast=bool)
DEBUG_SKIP_VALIDATE_ON_AUTHENTICATION = config("DEBUG_SKIP_VALIDATE_ON_AUTHENTICATION", cast=bool)
# Required if DEBUG is False
ALLOWED_HOSTS = config("ALLOWED_HOSTS", cast=Csv())
......@@ -133,8 +137,6 @@ SITE_DOMAIN = config("SITE_DOMAIN")
SITE_PROVIDER = config("SITE_PROVIDER")
SITE_PROVIDER_LINK = config("SITE_PROVIDER_LINK")
LINK_SUBSCRIPTION = config("LINK_SUBSCRIPTION")
#
# WOOCOMMERCE
#
......@@ -142,17 +144,16 @@ LINK_SUBSCRIPTION = config("LINK_SUBSCRIPTION")
WOO_URL = config("WOO_URL")
WOO_WP_API = config("WOO_WP_API", cast=bool)
WOO_VERSION = config("WOO_VERSION")
WOO_QUERY_STRING_AUTH = config("WOO_QUERY_STRING_AUTH", cast=bool) # required for OAuth over HTTPS
WOO_CONSUMER_KEY = secret_config("WOO_CONSUMER_KEY")
WOO_CONSUMER_SECRET = secret_config("WOO_CONSUMER_SECRET")
WOO_PRODUCT_LIST = config("WOO_PRODUCT_LIST", cast=Csv(int))
#
# WOO1 PARSER
# WOOSUB1 PARSER
#
WOO1_FIELD_LIST = config("WOO1_FIELD_LIST", cast=Csv())
WOOSUB1_PRODUCT_LIST = config("WOOSUB1_PRODUCT_LIST", cast=Csv(int))
#
# SSH CONNECTION TO OPENVPN SERVER
......@@ -163,6 +164,13 @@ OVPN_PORT = config("OVPN_PORT", cast=int)
OVPN_USERNAME = config("OVPN_USERNAME")
OVPN_FILEPATH = config("OVPN_FILEPATH")
#
# LIMIT MONITOR
#
LM_SERVICES = purist.limitmonitor.ServicesContainer
LM_PARSERS = purist.limitmonitor.ParserContainer
#
# LOGGING
#
......
import logging
from django.conf import settings
from django.contrib.auth.password_validation import MinimumLengthValidator as BaseValidator
from django.core import validators
from django.utils.deconstruct import deconstructible
from django.utils.translation import ugettext_lazy as _
from django_auth_ldap.backend import LDAPBackend as BaseBackend
from woocommerce import API as WOOCOMMERCE_API
from .models import User, UsernameValidator
log = logging.getLogger(__name__)
......@@ -14,21 +16,81 @@ class AuthenticationBackend(BaseBackend):
def __init__(self, *args, **kwargs):
super(AuthenticationBackend, self).__init__(*args, **kwargs)
def is_valid_jwt(self, username=None, password=None):
is_valid = False
try:
jwt_wcapi = WOOCOMMERCE_API(
url=settings.WOO_URL,
consumer_key=settings.WOO_CONSUMER_KEY,
consumer_secret=settings.WOO_CONSUMER_SECRET,
wp_api=True,
version="jwt-auth/v1", # required for JWT Authentication
query_string_auth=settings.WOO_QUERY_STRING_AUTH,
)
jwt_response = jwt_wcapi.post("token", {"username": username, "password": password})
jwt_status = jwt_response.status_code
jwt_token = jwt_response.json().get("token", None)
jwt_code = jwt_response.json().get("code", None)
known_codes = ["[jwt_auth] incorrect_password", "[jwt_auth] invalid_username"]
if jwt_status == 200 and jwt_token is not None:
is_valid = True
elif jwt_status == 403 and jwt_code in known_codes:
# recognised authentication failure
is_valid = False
else:
# raise exception for an unrecognised failure
raise Exception("Unrecognised JWT response: %s" % (jwt_response.json(),))
except Exception as e:
logging.exception("JWT authentication failed with an unrecognised error: %s" % (e,))
finally:
return is_valid
def authenticate(self, request=None, username=None, password=None, **kwargs):
model = self.get_user_model()
username = model.normalize_username(username)
user_model = User
normalized_username = user_model.normalize_username(username)
# first, validate username (even if it exists, username must be valid)
if not settings.DEBUG_SKIP_VALIDATE_ON_AUTHENTICATION:
validator = UsernameValidator()
validator(username)
# second, attempt LDAP authentication (with early exit on success)
user = super(AuthenticationBackend, self).authenticate(request, normalized_username, password, **kwargs)
if user is not None:
return user
# third, attempt WooCommerce/JWT authentication
# (if successful, create and return LDAP user, otherwise return None)
if self.is_valid_jwt(normalized_username, password):
# TODO: also validate, so that existing but invalid usernames are not permitted?
# try to get a preexisting user object
# otherwise create a new one
return super(AuthenticationBackend, self).authenticate(request, username, password, **kwargs)
try:
user = user_model.objects.get(username=username)
except user_model.DoesNotExist:
user = user_model(username=username, email=None)
# update/set user details
user.email = user.get_identity()
user.set_password(password)
user.save()
@deconstructible
class UsernameValidator(validators.RegexValidator):
regex = r'^[A-Za-z][A-Za-z0-9]*$'
message = _(
'Enter a valid username. Must start with a letter, followed by letters and numbers. No punctuation or special characters.'
)
return super(AuthenticationBackend, self).authenticate(request, normalized_username, password, **kwargs)
else:
return None
class PassphraseValidator(BaseValidator):
......
from collections import OrderedDict
from django.utils.translation import ugettext_lazy as _
class Container:
UNDEFINED = 0
MAP = OrderedDict([
(UNDEFINED, _("Undefined"))
])
@classmethod
def choices(cls):
choices = []
for code, name in cls.MAP.items():
choices.append((code, name))
return choices
@classmethod
def get_name_by_code(cls, code):
return cls.MAP.get(code, cls.MAP.get(cls.UNDEFINED))
class ServicesContainer(Container):
# CONSTANTS
UNDEFINED = 0
TUNNEL = 1
COMMUNICATION = 2
# MAPPING
MAP = OrderedDict([
(UNDEFINED, _("Undefined")),
(TUNNEL, _("Tunnel")),
(COMMUNICATION, _("Communication")),
])
class ParserContainer(Container):
# CONSTANTS
UNDEFINED = 0
WOO_PRODUCT_V1 = 1
WOO_SUBSCRIPTION_V1 = 2
# MAPPING
MAP = OrderedDict([
(UNDEFINED, _("Undefined")),
(WOO_PRODUCT_V1, _("WooCommerce Product v1")),
(WOO_SUBSCRIPTION_V1, _("WooCommerce Subscription v1")),
])
......@@ -3,19 +3,29 @@ import logging
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as BaseUserManager
from django.core import validators
from django.db import models
from django.utils import timezone
from django.utils.crypto import salted_hmac
from django.utils.deconstruct import deconstructible
from django.utils.translation import ugettext_lazy as _
from ldapregister.models import LdapPerson
from limitmonitor import models as limitmonitor_models
from limitmonitor.task_resources import common as limitmonitor_common
from .custom import UsernameValidator
log = logging.getLogger(__name__)
@deconstructible
class UsernameValidator(validators.RegexValidator):
regex = r'^[A-Za-z][A-Za-z0-9]*$'
message = _(
'Enter a valid username. Must start with a letter, followed by letters and numbers.'
' No punctuation or special characters.'
)
class UserManager(BaseUserManager):
def create_user(self, username, email=None, password=None, **extra_fields):
......@@ -50,10 +60,11 @@ class User(AbstractUser):
username = super(User, cls).normalize_username(username)
username = username.lower()
suffix = "@" + settings.SITE_DOMAIN.lower()
offset = 0 - len(suffix)
suffix = "@" <