diff --git a/codeforlife/models/__init__.py b/codeforlife/models/__init__.py index 9322030d..149d66fa 100644 --- a/codeforlife/models/__init__.py +++ b/codeforlife/models/__init__.py @@ -1,21 +1,50 @@ -"""Helpers for module "django.db.models". -https://docs.djangoproject.com/en/3.2/ref/models/ +""" +© Ocado Group +Created on 04/12/2023 at 14:36:56(+00:00). + +Base models. """ import typing as t -from django.db.models import Model as _Model +from django.db import models +from django.utils.translation import gettext_lazy as _ +from .fields import * -class Model(_Model): - """A base class for all Django models. - Args: - _Model (django.db.models.Model): Django's model class. - """ +class AbstractModel(models.Model): + """Base model to be inherited by other models throughout the CFL system.""" id: int pk: int - -AnyModel = t.TypeVar("AnyModel", bound=Model) + # https://docs.djangoproject.com/en/3.2/ref/models/fields/#django.db.models.DateField.auto_now + last_saved_at = models.DateTimeField( + _("last saved at"), + auto_now=True, + help_text=_( + "Record the last time the model was saved. This is used by our data" + " warehouse to know what data was modified since the last scheduled" + " data transfer from the database to the data warehouse." + ), + ) + + delete_after = models.DateTimeField( + _("delete after"), + null=True, + blank=True, + help_text=_( + "When this data is scheduled for deletion. Set to null if not" + " scheduled for deletion. This is used by our data warehouse to" + " transfer data that's been scheduled for deletion before it's" + " actually deleted. Data will actually be deleted in a CRON job" + " after this delete after." + ), + ) + + class Meta: + abstract = True + + +AnyModel = t.TypeVar("AnyModel", bound=AbstractModel) diff --git a/codeforlife/models/fields.py b/codeforlife/models/fields.py new file mode 100644 index 00000000..59ca74c0 --- /dev/null +++ b/codeforlife/models/fields.py @@ -0,0 +1,385 @@ +from django.db.models import TextChoices +from django.utils.translation import gettext_lazy as _ + + +class Country(TextChoices): + """ISO 3166-1 alpha-2 codes for each country.""" + + AF = "AF", _("Afghanistan") + AX = "AX", _("Åland Islands") + AL = "AL", _("Albania") + DZ = "DZ", _("Algeria") + AS = "AS", _("American Samoa") + AD = "AD", _("Andorra") + AO = "AO", _("Angola") + AI = "AI", _("Anguilla") + AQ = "AQ", _("Antarctica") + AG = "AG", _("Antigua and Barbuda") + AR = "AR", _("Argentina") + AM = "AM", _("Armenia") + AW = "AW", _("Aruba") + AU = "AU", _("Australia") + AT = "AT", _("Austria") + AZ = "AZ", _("Azerbaijan") + BS = "BS", _("Bahamas") + BH = "BH", _("Bahrain") + BD = "BD", _("Bangladesh") + BB = "BB", _("Barbados") + BY = "BY", _("Belarus") + BE = "BE", _("Belgium") + BZ = "BZ", _("Belize") + BJ = "BJ", _("Benin") + BM = "BM", _("Bermuda") + BT = "BT", _("Bhutan") + BO = "BO", _("Bolivia, Plurinational State of") + BQ = "BQ", _("Bonaire, Sint Eustatius and Saba") + BA = "BA", _("Bosnia and Herzegovina") + BW = "BW", _("Botswana") + BV = "BV", _("Bouvet Island") + BR = "BR", _("Brazil") + IO = "IO", _("British Indian Ocean Territory") + BN = "BN", _("Brunei Darussalam") + BG = "BG", _("Bulgaria") + BF = "BF", _("Burkina Faso") + BI = "BI", _("Burundi") + KH = "KH", _("Cambodia") + CM = "CM", _("Cameroon") + CA = "CA", _("Canada") + CV = "CV", _("Cape Verde") + KY = "KY", _("Cayman Islands") + CF = "CF", _("Central African Republic") + TD = "TD", _("Chad") + CL = "CL", _("Chile") + CN = "CN", _("China") + CX = "CX", _("Christmas Island") + CC = "CC", _("Cocos (Keeling) Islands") + CO = "CO", _("Colombia") + KM = "KM", _("Comoros") + CG = "CG", _("Congo") + CD = "CD", _("Congo, the Democratic Republic of the") + CK = "CK", _("Cook Islands") + CR = "CR", _("Costa Rica") + CI = "CI", _("Côte d'Ivoire") + HR = "HR", _("Croatia") + CU = "CU", _("Cuba") + CW = "CW", _("Curaçao") + CY = "CY", _("Cyprus") + CZ = "CZ", _("Czech Republic") + DK = "DK", _("Denmark") + DJ = "DJ", _("Djibouti") + DM = "DM", _("Dominica") + DO = "DO", _("Dominican Republic") + EC = "EC", _("Ecuador") + EG = "EG", _("Egypt") + SV = "SV", _("El Salvador") + GQ = "GQ", _("Equatorial Guinea") + ER = "ER", _("Eritrea") + EE = "EE", _("Estonia") + ET = "ET", _("Ethiopia") + FK = "FK", _("Falkland Islands (Malvinas)") + FO = "FO", _("Faroe Islands") + FJ = "FJ", _("Fiji") + FI = "FI", _("Finland") + FR = "FR", _("France") + GF = "GF", _("French Guiana") + PF = "PF", _("French Polynesia") + TF = "TF", _("French Southern Territories") + GA = "GA", _("Gabon") + GM = "GM", _("Gambia") + GE = "GE", _("Georgia") + DE = "DE", _("Germany") + GH = "GH", _("Ghana") + GI = "GI", _("Gibraltar") + GR = "GR", _("Greece") + GL = "GL", _("Greenland") + GD = "GD", _("Grenada") + GP = "GP", _("Guadeloupe") + GU = "GU", _("Guam") + GT = "GT", _("Guatemala") + GG = "GG", _("Guernsey") + GN = "GN", _("Guinea") + GW = "GW", _("Guinea-Bissau") + GY = "GY", _("Guyana") + HT = "HT", _("Haiti") + HM = "HM", _("Heard Island and McDonald Islands") + VA = "VA", _("Holy See (Vatican City State)") + HN = "HN", _("Honduras") + HK = "HK", _("Hong Kong") + HU = "HU", _("Hungary") + IS = "IS", _("Iceland") + IN = "IN", _("India") + ID = "ID", _("Indonesia") + IR = "IR", _("Iran, Islamic Republic of") + IQ = "IQ", _("Iraq") + IE = "IE", _("Ireland") + IM = "IM", _("Isle of Man") + IL = "IL", _("Israel") + IT = "IT", _("Italy") + JM = "JM", _("Jamaica") + JP = "JP", _("Japan") + JE = "JE", _("Jersey") + JO = "JO", _("Jordan") + KZ = "KZ", _("Kazakhstan") + KE = "KE", _("Kenya") + KI = "KI", _("Kiribati") + KP = "KP", _("Korea, Democratic People's Republic of") + KR = "KR", _("Korea, Republic of") + KW = "KW", _("Kuwait") + KG = "KG", _("Kyrgyzstan") + LA = "LA", _("Lao People's Democratic Republic") + LV = "LV", _("Latvia") + LB = "LB", _("Lebanon") + LS = "LS", _("Lesotho") + LR = "LR", _("Liberia") + LY = "LY", _("Libya") + LI = "LI", _("Liechtenstein") + LT = "LT", _("Lithuania") + LU = "LU", _("Luxembourg") + MO = "MO", _("Macao") + MK = "MK", _("Macedonia, the Former Yugoslav Republic of") + MG = "MG", _("Madagascar") + MW = "MW", _("Malawi") + MY = "MY", _("Malaysia") + MV = "MV", _("Maldives") + ML = "ML", _("Mali") + MT = "MT", _("Malta") + MH = "MH", _("Marshall Islands") + MQ = "MQ", _("Martinique") + MR = "MR", _("Mauritania") + MU = "MU", _("Mauritius") + YT = "YT", _("Mayotte") + MX = "MX", _("Mexico") + FM = "FM", _("Micronesia, Federated States of") + MD = "MD", _("Moldova, Republic of") + MC = "MC", _("Monaco") + MN = "MN", _("Mongolia") + ME = "ME", _("Montenegro") + MS = "MS", _("Montserrat") + MA = "MA", _("Morocco") + MZ = "MZ", _("Mozambique") + MM = "MM", _("Myanmar") + NA = "NA", _("Namibia") + NR = "NR", _("Nauru") + NP = "NP", _("Nepal") + NL = "NL", _("Netherlands") + NC = "NC", _("New Caledonia") + NZ = "NZ", _("New Zealand") + NI = "NI", _("Nicaragua") + NE = "NE", _("Niger") + NG = "NG", _("Nigeria") + NU = "NU", _("Niue") + NF = "NF", _("Norfolk Island") + MP = "MP", _("Northern Mariana Islands") + NO = "NO", _("Norway") + OM = "OM", _("Oman") + PK = "PK", _("Pakistan") + PW = "PW", _("Palau") + PS = "PS", _("Palestine, State of") + PA = "PA", _("Panama") + PG = "PG", _("Papua New Guinea") + PY = "PY", _("Paraguay") + PE = "PE", _("Peru") + PH = "PH", _("Philippines") + PN = "PN", _("Pitcairn") + PL = "PL", _("Poland") + PT = "PT", _("Portugal") + PR = "PR", _("Puerto Rico") + QA = "QA", _("Qatar") + RE = "RE", _("Réunion") + RO = "RO", _("Romania") + RU = "RU", _("Russian Federation") + RW = "RW", _("Rwanda") + BL = "BL", _("Saint Barthélemy") + SH = "SH", _("Saint Helena, Ascension and Tristan da Cunha") + KN = "KN", _("Saint Kitts and Nevis") + LC = "LC", _("Saint Lucia") + MF = "MF", _("Saint Martin (French part)") + PM = "PM", _("Saint Pierre and Miquelon") + VC = "VC", _("Saint Vincent and the Grenadines") + WS = "WS", _("Samoa") + SM = "SM", _("San Marino") + ST = "ST", _("Sao Tome and Principe") + SA = "SA", _("Saudi Arabia") + SN = "SN", _("Senegal") + RS = "RS", _("Serbia") + SC = "SC", _("Seychelles") + SL = "SL", _("Sierra Leone") + SG = "SG", _("Singapore") + SX = "SX", _("Sint Maarten (Dutch part)") + SK = "SK", _("Slovakia") + SI = "SI", _("Slovenia") + SB = "SB", _("Solomon Islands") + SO = "SO", _("Somalia") + ZA = "ZA", _("South Africa") + GS = "GS", _("South Georgia and the South Sandwich Islands") + SS = "SS", _("South Sudan") + ES = "ES", _("Spain") + LK = "LK", _("Sri Lanka") + SD = "SD", _("Sudan") + SR = "SR", _("Suriname") + SJ = "SJ", _("Svalbard and Jan Mayen") + SZ = "SZ", _("Swaziland") + SE = "SE", _("Sweden") + CH = "CH", _("Switzerland") + SY = "SY", _("Syrian Arab Republic") + TW = "TW", _("Taiwan, Province of China") + TJ = "TJ", _("Tajikistan") + TZ = "TZ", _("Tanzania, United Republic of") + TH = "TH", _("Thailand") + TL = "TL", _("Timor-Leste") + TG = "TG", _("Togo") + TK = "TK", _("Tokelau") + TO = "TO", _("Tonga") + TT = "TT", _("Trinidad and Tobago") + TN = "TN", _("Tunisia") + TR = "TR", _("Turkey") + TM = "TM", _("Turkmenistan") + TC = "TC", _("Turks and Caicos Islands") + TV = "TV", _("Tuvalu") + UG = "UG", _("Uganda") + UA = "UA", _("Ukraine") + AE = "AE", _("United Arab Emirates") + GB = "GB", _("United Kingdom") + US = "US", _("United States") + UM = "UM", _("United States Minor Outlying Islands") + UY = "UY", _("Uruguay") + UZ = "UZ", _("Uzbekistan") + VU = "VU", _("Vanuatu") + VE = "VE", _("Venezuela, Bolivarian Republic of") + VN = "VN", _("Viet Nam") + VG = "VG", _("Virgin Islands, British") + VI = "VI", _("Virgin Islands, U.S.") + WF = "WF", _("Wallis and Futuna") + EH = "EH", _("Western Sahara") + YE = "YE", _("Yemen") + ZM = "ZM", _("Zambia") + ZW = "ZW", _("Zimbabwe") + + +class UkCounty(TextChoices): + ABERDEEN_CITY = "Aberdeen City", _("Aberdeen City") + ABERDEENSHIRE = "Aberdeenshire", _("Aberdeenshire") + ANGUS = "Angus", _("Angus") + ARGYLL_AND_BUTE = "Argyll and Bute", _("Argyll and Bute") + BEDFORDSHIRE = "Bedfordshire", _("Bedfordshire") + BELFAST = "Belfast", _("Belfast") + BELFAST_GREATER = "Belfast Greater", _("Belfast Greater") + BERKSHIRE = "Berkshire", _("Berkshire") + BLAENAU_GWENT = "Blaenau Gwent", _("Blaenau Gwent") + BRIDGEND = "Bridgend", _("Bridgend") + BUCKINGHAMSHIRE = "Buckinghamshire", _("Buckinghamshire") + CAERPHILLY = "Caerphilly", _("Caerphilly") + CAMBRIDGESHIRE = "Cambridgeshire", _("Cambridgeshire") + CARDIFF = "Cardiff", _("Cardiff") + CARMARTHENSHIRE = "Carmarthenshire", _("Carmarthenshire") + CEREDIGION = "Ceredigion", _("Ceredigion") + CHANNEL_ISLANDS = "Channel Islands", _("Channel Islands") + CHESHIRE = "Cheshire", _("Cheshire") + CITY_OF_EDINBURGH = "City of Edinburgh", _("City of Edinburgh") + CLACKMANNANSHIRE = "Clackmannanshire", _("Clackmannanshire") + CONWY = "Conwy", _("Conwy") + CORNWALL = "Cornwall", _("Cornwall") + COUNTY_ANTRIM = "County Antrim", _("County Antrim") + COUNTY_ARMAGH = "County Armagh", _("County Armagh") + COUNTY_DOWN = "County Down", _("County Down") + COUNTY_FERMANAGH = "County Fermanagh", _("County Fermanagh") + COUNTY_LONDONDERRY = "County Londonderry", _("County Londonderry") + COUNTY_TYRONE = "County Tyrone", _("County Tyrone") + COUNTY_OF_BRISTOL = "County of Bristol", _("County of Bristol") + CUMBRIA = "Cumbria", _("Cumbria") + DENBIGHSHIRE = "Denbighshire", _("Denbighshire") + DERBYSHIRE = "Derbyshire", _("Derbyshire") + DEVON = "Devon", _("Devon") + DORSET = "Dorset", _("Dorset") + DUMFRIES_AND_GALLOWAY = "Dumfries and Galloway", _("Dumfries and Galloway") + DUNBARTONSHIRE = "Dunbartonshire", _("Dunbartonshire") + DUNDEE_CITY = "Dundee City", _("Dundee City") + DURHAM = "Durham", _("Durham") + EAST_AYRSHIRE = "East Ayrshire", _("East Ayrshire") + EAST_DUNBARTONSHIRE = "East Dunbartonshire", _("East Dunbartonshire") + EAST_LOTHIAN = "East Lothian", _("East Lothian") + EAST_RENFREWSHIRE = "East Renfrewshire", _("East Renfrewshire") + EAST_RIDING_OF_YORKSHIRE = "East Riding of Yorkshire", _( + "East Riding of Yorkshire" + ) + EAST_SUSSEX = "East Sussex", _("East Sussex") + ESSEX = "Essex", _("Essex") + FALKIRK = "Falkirk", _("Falkirk") + FIFE = "Fife", _("Fife") + FLINTSHIRE = "Flintshire", _("Flintshire") + GLASGOW_CITY = "Glasgow City", _("Glasgow City") + GLOUCESTERSHIRE = "Gloucestershire", _("Gloucestershire") + GREATER_LONDON = "Greater London", _("Greater London") + GREATER_MANCHESTER = "Greater Manchester", _("Greater Manchester") + GUERNSEY_CHANNEL_ISLANDS = "Guernsey Channel Islands", _( + "Guernsey Channel Islands" + ) + GWYNEDD = "Gwynedd", _("Gwynedd") + HAMPSHIRE = "Hampshire", _("Hampshire") + HEREFORD_AND_WORCESTER = "Hereford and Worcester", _( + "Hereford and Worcester" + ) + HEREFORDSHIRE = "Herefordshire", _("Herefordshire") + HERTFORDSHIRE = "Hertfordshire", _("Hertfordshire") + HIGHLAND = "Highland", _("Highland") + INVERCLYDE = "Inverclyde", _("Inverclyde") + INVERNESS = "Inverness", _("Inverness") + ISLE_OF_ANGLESEY = "Isle of Anglesey", _("Isle of Anglesey") + ISLE_OF_BARRA = "Isle of Barra", _("Isle of Barra") + ISLE_OF_MAN = "Isle of Man", _("Isle of Man") + ISLE_OF_WIGHT = "Isle of Wight", _("Isle of Wight") + JERSEY_CHANNEL_ISLANDS = "Jersey Channel Islands", _( + "Jersey Channel Islands" + ) + KENT = "Kent", _("Kent") + LANCASHIRE = "Lancashire", _("Lancashire") + LEICESTERSHIRE = "Leicestershire", _("Leicestershire") + LINCOLNSHIRE = "Lincolnshire", _("Lincolnshire") + MERSEYSIDE = "Merseyside", _("Merseyside") + MERTHYR_TYDFIL = "Merthyr Tydfil", _("Merthyr Tydfil") + MIDLOTHIAN = "Midlothian", _("Midlothian") + MONMOUTHSHIRE = "Monmouthshire", _("Monmouthshire") + MORAY = "Moray", _("Moray") + NEATH_PORT_TALBOT = "Neath Port Talbot", _("Neath Port Talbot") + NEWPORT = "Newport", _("Newport") + NORFOLK = "Norfolk", _("Norfolk") + NORTH_AYRSHIRE = "North Ayrshire", _("North Ayrshire") + NORTH_LANARKSHIRE = "North Lanarkshire", _("North Lanarkshire") + NORTH_YORKSHIRE = "North Yorkshire", _("North Yorkshire") + NORTHAMPTONSHIRE = "Northamptonshire", _("Northamptonshire") + NORTHUMBERLAND = "Northumberland", _("Northumberland") + NOTTINGHAMSHIRE = "Nottinghamshire", _("Nottinghamshire") + ORKNEY = "Orkney", _("Orkney") + ORKNEY_ISLANDS = "Orkney Islands", _("Orkney Islands") + OXFORDSHIRE = "Oxfordshire", _("Oxfordshire") + PEMBROKESHIRE = "Pembrokeshire", _("Pembrokeshire") + PERTH_AND_KINROSS = "Perth and Kinross", _("Perth and Kinross") + POWYS = "Powys", _("Powys") + RENFREWSHIRE = "Renfrewshire", _("Renfrewshire") + RHONDDA_CYNON_TAFF = "Rhondda Cynon Taff", _("Rhondda Cynon Taff") + RUTLAND = "Rutland", _("Rutland") + SCOTTISH_BORDERS = "Scottish Borders", _("Scottish Borders") + SHETLAND_ISLANDS = "Shetland Islands", _("Shetland Islands") + SHROPSHIRE = "Shropshire", _("Shropshire") + SOMERSET = "Somerset", _("Somerset") + SOUTH_AYRSHIRE = "South Ayrshire", _("South Ayrshire") + SOUTH_LANARKSHIRE = "South Lanarkshire", _("South Lanarkshire") + SOUTH_YORKSHIRE = "South Yorkshire", _("South Yorkshire") + STAFFORDSHIRE = "Staffordshire", _("Staffordshire") + STIRLING = "Stirling", _("Stirling") + SUFFOLK = "Suffolk", _("Suffolk") + SURREY = "Surrey", _("Surrey") + SWANSEA = "Swansea", _("Swansea") + TORFAEN = "Torfaen", _("Torfaen") + TYNE_AND_WEAR = "Tyne and Wear", _("Tyne and Wear") + VALE_OF_GLAMORGAN = "Vale of Glamorgan", _("Vale of Glamorgan") + WARWICKSHIRE = "Warwickshire", _("Warwickshire") + WEST_DUNBART = "West Dunbart", _("West Dunbart") + WEST_LOTHIAN = "West Lothian", _("West Lothian") + WEST_MIDLANDS = "West Midlands", _("West Midlands") + WEST_SUSSEX = "West Sussex", _("West Sussex") + WEST_YORKSHIRE = "West Yorkshire", _("West Yorkshire") + WESTERN_ISLES = "Western Isles", _("Western Isles") + WILTSHIRE = "Wiltshire", _("Wiltshire") + WORCESTERSHIRE = "Worcestershire", _("Worcestershire") + WREXHAM = "Wrexham", _("Wrexham") diff --git a/codeforlife/settings/django.py b/codeforlife/settings/django.py index 5d33718b..2ec9ef00 100644 --- a/codeforlife/settings/django.py +++ b/codeforlife/settings/django.py @@ -19,6 +19,7 @@ # https://docs.djangoproject.com/en/3.2/topics/auth/default/ LOGIN_URL = f"{SERVICE_API_URL}/session/expired/" +AUTH_USER_MODEL = "user.User" # Authentication backends # https://docs.djangoproject.com/en/3.2/ref/settings/#authentication-backends diff --git a/codeforlife/user/migrations/0001_initial.py b/codeforlife/user/migrations/0001_initial.py index 034c7cbe..487fc504 100644 --- a/codeforlife/user/migrations/0001_initial.py +++ b/codeforlife/user/migrations/0001_initial.py @@ -1,9 +1,13 @@ -# Generated by Django 3.2.20 on 2023-09-29 17:53 +# Generated by Django 3.2.20 on 2023-12-06 16:05 +from django.conf import settings import django.contrib.auth.models +import django.contrib.auth.validators import django.core.validators from django.db import migrations, models import django.db.models.deletion +import django.db.models.expressions +import django.utils.timezone class Migration(migrations.Migration): @@ -18,24 +22,72 @@ class Migration(migrations.Migration): migrations.CreateModel( name='User', fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('last_saved_at', models.DateTimeField(auto_now=True, help_text='Record the last time the model was saved. This is used by our data warehouse to know what data was modified since the last scheduled data transfer from the database to the data warehouse.', verbose_name='last saved at')), + ('delete_after', models.DateTimeField(blank=True, help_text="When this data is scheduled for deletion. Set to null if not scheduled for deletion. This is used by our data warehouse to transfer data that's been scheduled for deletion before it's actually deleted. Data will actually be deleted in a CRON job after this delete after.", null=True, verbose_name='delete after')), + ('otp_secret', models.CharField(editable=False, help_text='Secret used to generate a OTP.', max_length=40, null=True, verbose_name='OTP secret')), + ('last_otp_for_time', models.DateTimeField(editable=False, help_text='Used to prevent replay attacks, where the same OTP is used for different times.', null=True, verbose_name='last OTP for-time')), ], - options={ - 'proxy': True, - 'indexes': [], - 'constraints': [], - }, - bases=('auth.user',), managers=[ ('objects', django.contrib.auth.models.UserManager()), ], ), + migrations.CreateModel( + name='AuthFactor', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('last_saved_at', models.DateTimeField(auto_now=True, help_text='Record the last time the model was saved. This is used by our data warehouse to know what data was modified since the last scheduled data transfer from the database to the data warehouse.', verbose_name='last saved at')), + ('delete_after', models.DateTimeField(blank=True, help_text="When this data is scheduled for deletion. Set to null if not scheduled for deletion. This is used by our data warehouse to transfer data that's been scheduled for deletion before it's actually deleted. Data will actually be deleted in a CRON job after this delete after.", null=True, verbose_name='delete after')), + ('type', models.TextField(choices=[('otp', 'one-time password')], help_text='The type of authentication factor.', verbose_name='auth factor type')), + ], + ), + migrations.CreateModel( + name='Class', + fields=[ + ('last_saved_at', models.DateTimeField(auto_now=True, help_text='Record the last time the model was saved. This is used by our data warehouse to know what data was modified since the last scheduled data transfer from the database to the data warehouse.', verbose_name='last saved at')), + ('delete_after', models.DateTimeField(blank=True, help_text="When this data is scheduled for deletion. Set to null if not scheduled for deletion. This is used by our data warehouse to transfer data that's been scheduled for deletion before it's actually deleted. Data will actually be deleted in a CRON job after this delete after.", null=True, verbose_name='delete after')), + ('id', models.CharField(editable=False, help_text='Uniquely identifies a class.', max_length=5, primary_key=True, serialize=False, validators=[django.core.validators.MinLengthValidator(5), django.core.validators.RegexValidator(code='id_not_alphanumeric', message='ID must be alphanumeric.', regex='^[0-9a-zA-Z]*$')], verbose_name='identifier')), + ('read_classmates_data', models.BooleanField(default=False, help_text="Designates whether students in this class can see their fellow classmates' data.", verbose_name='read classmates data')), + ('accept_requests_until', models.DateTimeField(blank=True, help_text="A point in the future until which requests from students to join this class are accepted. Set to null if it's not accepting requests.", null=True, verbose_name='accept student join requests until')), + ], + ), + migrations.CreateModel( + name='OtpBypassToken', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('last_saved_at', models.DateTimeField(auto_now=True, help_text='Record the last time the model was saved. This is used by our data warehouse to know what data was modified since the last scheduled data transfer from the database to the data warehouse.', verbose_name='last saved at')), + ('delete_after', models.DateTimeField(blank=True, help_text="When this data is scheduled for deletion. Set to null if not scheduled for deletion. This is used by our data warehouse to transfer data that's been scheduled for deletion before it's actually deleted. Data will actually be deleted in a CRON job after this delete after.", null=True, verbose_name='delete after')), + ('token', models.CharField(max_length=8, validators=[django.core.validators.MinLengthValidator(8)])), + ], + ), + migrations.CreateModel( + name='School', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('last_saved_at', models.DateTimeField(auto_now=True, help_text='Record the last time the model was saved. This is used by our data warehouse to know what data was modified since the last scheduled data transfer from the database to the data warehouse.', verbose_name='last saved at')), + ('delete_after', models.DateTimeField(blank=True, help_text="When this data is scheduled for deletion. Set to null if not scheduled for deletion. This is used by our data warehouse to transfer data that's been scheduled for deletion before it's actually deleted. Data will actually be deleted in a CRON job after this delete after.", null=True, verbose_name='delete after')), + ('name', models.CharField(help_text="The school's name.", max_length=200, unique=True, verbose_name='name')), + ('country', models.TextField(blank=True, choices=[('AF', 'Afghanistan'), ('AX', 'Åland Islands'), ('AL', 'Albania'), ('DZ', 'Algeria'), ('AS', 'American Samoa'), ('AD', 'Andorra'), ('AO', 'Angola'), ('AI', 'Anguilla'), ('AQ', 'Antarctica'), ('AG', 'Antigua and Barbuda'), ('AR', 'Argentina'), ('AM', 'Armenia'), ('AW', 'Aruba'), ('AU', 'Australia'), ('AT', 'Austria'), ('AZ', 'Azerbaijan'), ('BS', 'Bahamas'), ('BH', 'Bahrain'), ('BD', 'Bangladesh'), ('BB', 'Barbados'), ('BY', 'Belarus'), ('BE', 'Belgium'), ('BZ', 'Belize'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BT', 'Bhutan'), ('BO', 'Bolivia, Plurinational State of'), ('BQ', 'Bonaire, Sint Eustatius and Saba'), ('BA', 'Bosnia and Herzegovina'), ('BW', 'Botswana'), ('BV', 'Bouvet Island'), ('BR', 'Brazil'), ('IO', 'British Indian Ocean Territory'), ('BN', 'Brunei Darussalam'), ('BG', 'Bulgaria'), ('BF', 'Burkina Faso'), ('BI', 'Burundi'), ('KH', 'Cambodia'), ('CM', 'Cameroon'), ('CA', 'Canada'), ('CV', 'Cape Verde'), ('KY', 'Cayman Islands'), ('CF', 'Central African Republic'), ('TD', 'Chad'), ('CL', 'Chile'), ('CN', 'China'), ('CX', 'Christmas Island'), ('CC', 'Cocos (Keeling) Islands'), ('CO', 'Colombia'), ('KM', 'Comoros'), ('CG', 'Congo'), ('CD', 'Congo, the Democratic Republic of the'), ('CK', 'Cook Islands'), ('CR', 'Costa Rica'), ('CI', "Côte d'Ivoire"), ('HR', 'Croatia'), ('CU', 'Cuba'), ('CW', 'Curaçao'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DK', 'Denmark'), ('DJ', 'Djibouti'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('EC', 'Ecuador'), ('EG', 'Egypt'), ('SV', 'El Salvador'), ('GQ', 'Equatorial Guinea'), ('ER', 'Eritrea'), ('EE', 'Estonia'), ('ET', 'Ethiopia'), ('FK', 'Falkland Islands (Malvinas)'), ('FO', 'Faroe Islands'), ('FJ', 'Fiji'), ('FI', 'Finland'), ('FR', 'France'), ('GF', 'French Guiana'), ('PF', 'French Polynesia'), ('TF', 'French Southern Territories'), ('GA', 'Gabon'), ('GM', 'Gambia'), ('GE', 'Georgia'), ('DE', 'Germany'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GR', 'Greece'), ('GL', 'Greenland'), ('GD', 'Grenada'), ('GP', 'Guadeloupe'), ('GU', 'Guam'), ('GT', 'Guatemala'), ('GG', 'Guernsey'), ('GN', 'Guinea'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HT', 'Haiti'), ('HM', 'Heard Island and McDonald Islands'), ('VA', 'Holy See (Vatican City State)'), ('HN', 'Honduras'), ('HK', 'Hong Kong'), ('HU', 'Hungary'), ('IS', 'Iceland'), ('IN', 'India'), ('ID', 'Indonesia'), ('IR', 'Iran, Islamic Republic of'), ('IQ', 'Iraq'), ('IE', 'Ireland'), ('IM', 'Isle of Man'), ('IL', 'Israel'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JP', 'Japan'), ('JE', 'Jersey'), ('JO', 'Jordan'), ('KZ', 'Kazakhstan'), ('KE', 'Kenya'), ('KI', 'Kiribati'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KG', 'Kyrgyzstan'), ('LA', "Lao People's Democratic Republic"), ('LV', 'Latvia'), ('LB', 'Lebanon'), ('LS', 'Lesotho'), ('LR', 'Liberia'), ('LY', 'Libya'), ('LI', 'Liechtenstein'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('MO', 'Macao'), ('MK', 'Macedonia, the Former Yugoslav Republic of'), ('MG', 'Madagascar'), ('MW', 'Malawi'), ('MY', 'Malaysia'), ('MV', 'Maldives'), ('ML', 'Mali'), ('MT', 'Malta'), ('MH', 'Marshall Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MU', 'Mauritius'), ('YT', 'Mayotte'), ('MX', 'Mexico'), ('FM', 'Micronesia, Federated States of'), ('MD', 'Moldova, Republic of'), ('MC', 'Monaco'), ('MN', 'Mongolia'), ('ME', 'Montenegro'), ('MS', 'Montserrat'), ('MA', 'Morocco'), ('MZ', 'Mozambique'), ('MM', 'Myanmar'), ('NA', 'Namibia'), ('NR', 'Nauru'), ('NP', 'Nepal'), ('NL', 'Netherlands'), ('NC', 'New Caledonia'), ('NZ', 'New Zealand'), ('NI', 'Nicaragua'), ('NE', 'Niger'), ('NG', 'Nigeria'), ('NU', 'Niue'), ('NF', 'Norfolk Island'), ('MP', 'Northern Mariana Islands'), ('NO', 'Norway'), ('OM', 'Oman'), ('PK', 'Pakistan'), ('PW', 'Palau'), ('PS', 'Palestine, State of'), ('PA', 'Panama'), ('PG', 'Papua New Guinea'), ('PY', 'Paraguay'), ('PE', 'Peru'), ('PH', 'Philippines'), ('PN', 'Pitcairn'), ('PL', 'Poland'), ('PT', 'Portugal'), ('PR', 'Puerto Rico'), ('QA', 'Qatar'), ('RE', 'Réunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('BL', 'Saint Barthélemy'), ('SH', 'Saint Helena, Ascension and Tristan da Cunha'), ('KN', 'Saint Kitts and Nevis'), ('LC', 'Saint Lucia'), ('MF', 'Saint Martin (French part)'), ('PM', 'Saint Pierre and Miquelon'), ('VC', 'Saint Vincent and the Grenadines'), ('WS', 'Samoa'), ('SM', 'San Marino'), ('ST', 'Sao Tome and Principe'), ('SA', 'Saudi Arabia'), ('SN', 'Senegal'), ('RS', 'Serbia'), ('SC', 'Seychelles'), ('SL', 'Sierra Leone'), ('SG', 'Singapore'), ('SX', 'Sint Maarten (Dutch part)'), ('SK', 'Slovakia'), ('SI', 'Slovenia'), ('SB', 'Solomon Islands'), ('SO', 'Somalia'), ('ZA', 'South Africa'), ('GS', 'South Georgia and the South Sandwich Islands'), ('SS', 'South Sudan'), ('ES', 'Spain'), ('LK', 'Sri Lanka'), ('SD', 'Sudan'), ('SR', 'Suriname'), ('SJ', 'Svalbard and Jan Mayen'), ('SZ', 'Swaziland'), ('SE', 'Sweden'), ('CH', 'Switzerland'), ('SY', 'Syrian Arab Republic'), ('TW', 'Taiwan, Province of China'), ('TJ', 'Tajikistan'), ('TZ', 'Tanzania, United Republic of'), ('TH', 'Thailand'), ('TL', 'Timor-Leste'), ('TG', 'Togo'), ('TK', 'Tokelau'), ('TO', 'Tonga'), ('TT', 'Trinidad and Tobago'), ('TN', 'Tunisia'), ('TR', 'Turkey'), ('TM', 'Turkmenistan'), ('TC', 'Turks and Caicos Islands'), ('TV', 'Tuvalu'), ('UG', 'Uganda'), ('UA', 'Ukraine'), ('AE', 'United Arab Emirates'), ('GB', 'United Kingdom'), ('US', 'United States'), ('UM', 'United States Minor Outlying Islands'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VU', 'Vanuatu'), ('VE', 'Venezuela, Bolivarian Republic of'), ('VN', 'Viet Nam'), ('VG', 'Virgin Islands, British'), ('VI', 'Virgin Islands, U.S.'), ('WF', 'Wallis and Futuna'), ('EH', 'Western Sahara'), ('YE', 'Yemen'), ('ZM', 'Zambia'), ('ZW', 'Zimbabwe')], help_text="The school's country.", null=True, verbose_name='country')), + ('uk_county', models.TextField(blank=True, choices=[('Aberdeen City', 'Aberdeen City'), ('Aberdeenshire', 'Aberdeenshire'), ('Angus', 'Angus'), ('Argyll and Bute', 'Argyll and Bute'), ('Bedfordshire', 'Bedfordshire'), ('Belfast', 'Belfast'), ('Belfast Greater', 'Belfast Greater'), ('Berkshire', 'Berkshire'), ('Blaenau Gwent', 'Blaenau Gwent'), ('Bridgend', 'Bridgend'), ('Buckinghamshire', 'Buckinghamshire'), ('Caerphilly', 'Caerphilly'), ('Cambridgeshire', 'Cambridgeshire'), ('Cardiff', 'Cardiff'), ('Carmarthenshire', 'Carmarthenshire'), ('Ceredigion', 'Ceredigion'), ('Channel Islands', 'Channel Islands'), ('Cheshire', 'Cheshire'), ('City of Edinburgh', 'City of Edinburgh'), ('Clackmannanshire', 'Clackmannanshire'), ('Conwy', 'Conwy'), ('Cornwall', 'Cornwall'), ('County Antrim', 'County Antrim'), ('County Armagh', 'County Armagh'), ('County Down', 'County Down'), ('County Fermanagh', 'County Fermanagh'), ('County Londonderry', 'County Londonderry'), ('County Tyrone', 'County Tyrone'), ('County of Bristol', 'County of Bristol'), ('Cumbria', 'Cumbria'), ('Denbighshire', 'Denbighshire'), ('Derbyshire', 'Derbyshire'), ('Devon', 'Devon'), ('Dorset', 'Dorset'), ('Dumfries and Galloway', 'Dumfries and Galloway'), ('Dunbartonshire', 'Dunbartonshire'), ('Dundee City', 'Dundee City'), ('Durham', 'Durham'), ('East Ayrshire', 'East Ayrshire'), ('East Dunbartonshire', 'East Dunbartonshire'), ('East Lothian', 'East Lothian'), ('East Renfrewshire', 'East Renfrewshire'), ('East Riding of Yorkshire', 'East Riding of Yorkshire'), ('East Sussex', 'East Sussex'), ('Essex', 'Essex'), ('Falkirk', 'Falkirk'), ('Fife', 'Fife'), ('Flintshire', 'Flintshire'), ('Glasgow City', 'Glasgow City'), ('Gloucestershire', 'Gloucestershire'), ('Greater London', 'Greater London'), ('Greater Manchester', 'Greater Manchester'), ('Guernsey Channel Islands', 'Guernsey Channel Islands'), ('Gwynedd', 'Gwynedd'), ('Hampshire', 'Hampshire'), ('Hereford and Worcester', 'Hereford and Worcester'), ('Herefordshire', 'Herefordshire'), ('Hertfordshire', 'Hertfordshire'), ('Highland', 'Highland'), ('Inverclyde', 'Inverclyde'), ('Inverness', 'Inverness'), ('Isle of Anglesey', 'Isle of Anglesey'), ('Isle of Barra', 'Isle of Barra'), ('Isle of Man', 'Isle of Man'), ('Isle of Wight', 'Isle of Wight'), ('Jersey Channel Islands', 'Jersey Channel Islands'), ('Kent', 'Kent'), ('Lancashire', 'Lancashire'), ('Leicestershire', 'Leicestershire'), ('Lincolnshire', 'Lincolnshire'), ('Merseyside', 'Merseyside'), ('Merthyr Tydfil', 'Merthyr Tydfil'), ('Midlothian', 'Midlothian'), ('Monmouthshire', 'Monmouthshire'), ('Moray', 'Moray'), ('Neath Port Talbot', 'Neath Port Talbot'), ('Newport', 'Newport'), ('Norfolk', 'Norfolk'), ('North Ayrshire', 'North Ayrshire'), ('North Lanarkshire', 'North Lanarkshire'), ('North Yorkshire', 'North Yorkshire'), ('Northamptonshire', 'Northamptonshire'), ('Northumberland', 'Northumberland'), ('Nottinghamshire', 'Nottinghamshire'), ('Orkney', 'Orkney'), ('Orkney Islands', 'Orkney Islands'), ('Oxfordshire', 'Oxfordshire'), ('Pembrokeshire', 'Pembrokeshire'), ('Perth and Kinross', 'Perth and Kinross'), ('Powys', 'Powys'), ('Renfrewshire', 'Renfrewshire'), ('Rhondda Cynon Taff', 'Rhondda Cynon Taff'), ('Rutland', 'Rutland'), ('Scottish Borders', 'Scottish Borders'), ('Shetland Islands', 'Shetland Islands'), ('Shropshire', 'Shropshire'), ('Somerset', 'Somerset'), ('South Ayrshire', 'South Ayrshire'), ('South Lanarkshire', 'South Lanarkshire'), ('South Yorkshire', 'South Yorkshire'), ('Staffordshire', 'Staffordshire'), ('Stirling', 'Stirling'), ('Suffolk', 'Suffolk'), ('Surrey', 'Surrey'), ('Swansea', 'Swansea'), ('Torfaen', 'Torfaen'), ('Tyne and Wear', 'Tyne and Wear'), ('Vale of Glamorgan', 'Vale of Glamorgan'), ('Warwickshire', 'Warwickshire'), ('West Dunbart', 'West Dunbart'), ('West Lothian', 'West Lothian'), ('West Midlands', 'West Midlands'), ('West Sussex', 'West Sussex'), ('West Yorkshire', 'West Yorkshire'), ('Western Isles', 'Western Isles'), ('Wiltshire', 'Wiltshire'), ('Worcestershire', 'Worcestershire'), ('Wrexham', 'Wrexham')], help_text="The school's county within the United Kingdom. This value may only be set if the school's country is set to UK.", null=True, verbose_name='united kingdom county')), + ], + ), migrations.CreateModel( name='Session', fields=[ ('session_key', models.CharField(max_length=40, primary_key=True, serialize=False, verbose_name='session key')), ('session_data', models.TextField(verbose_name='session data')), ('expire_date', models.DateTimeField(db_index=True, verbose_name='expire date')), - ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='user.user')), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ 'verbose_name': 'session', @@ -44,14 +96,30 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name='AuthFactor', + name='Teacher', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('last_saved_at', models.DateTimeField(auto_now=True, help_text='Record the last time the model was saved. This is used by our data warehouse to know what data was modified since the last scheduled data transfer from the database to the data warehouse.', verbose_name='last saved at')), + ('delete_after', models.DateTimeField(blank=True, help_text="When this data is scheduled for deletion. Set to null if not scheduled for deletion. This is used by our data warehouse to transfer data that's been scheduled for deletion before it's actually deleted. Data will actually be deleted in a CRON job after this delete after.", null=True, verbose_name='delete after')), + ('is_admin', models.BooleanField(default=False, help_text='Designates if the teacher has admin privileges.', verbose_name='is administrator')), + ('school', models.OneToOneField(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='teachers', to='user.school')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Student', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('type', models.TextField(choices=[('otp', 'one-time password')])), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_factors', to='user.user')), + ('last_saved_at', models.DateTimeField(auto_now=True, help_text='Record the last time the model was saved. This is used by our data warehouse to know what data was modified since the last scheduled data transfer from the database to the data warehouse.', verbose_name='last saved at')), + ('delete_after', models.DateTimeField(blank=True, help_text="When this data is scheduled for deletion. Set to null if not scheduled for deletion. This is used by our data warehouse to transfer data that's been scheduled for deletion before it's actually deleted. Data will actually be deleted in a CRON job after this delete after.", null=True, verbose_name='delete after')), + ('direct_login_key', models.CharField(editable=False, help_text='A unique key that allows a student to log directly into theiraccount.', max_length=64, unique=True, validators=[django.core.validators.MinLengthValidator(64)], verbose_name='direct login key')), + ('klass', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='students', to='user.class')), + ('school', models.OneToOneField(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='students', to='user.school')), ], options={ - 'unique_together': {('user', 'type')}, + 'abstract': False, }, ), migrations.CreateModel( @@ -61,19 +129,83 @@ class Migration(migrations.Migration): ('auth_factor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='session_auth_factors', to='user.authfactor')), ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='session_auth_factors', to='user.session')), ], - options={ - 'unique_together': {('session', 'auth_factor')}, - }, ), migrations.CreateModel( - name='OtpBypassToken', + name='SchoolTeacherInvitation', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('token', models.CharField(max_length=8, validators=[django.core.validators.MinLengthValidator(8)])), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='otp_bypass_tokens', to='user.user')), + ('last_saved_at', models.DateTimeField(auto_now=True, help_text='Record the last time the model was saved. This is used by our data warehouse to know what data was modified since the last scheduled data transfer from the database to the data warehouse.', verbose_name='last saved at')), + ('delete_after', models.DateTimeField(blank=True, help_text="When this data is scheduled for deletion. Set to null if not scheduled for deletion. This is used by our data warehouse to transfer data that's been scheduled for deletion before it's actually deleted. Data will actually be deleted in a CRON job after this delete after.", null=True, verbose_name='delete after')), + ('school', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teacher_invitations', to='user.school')), + ('teacher', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='school_invitations', to='user.teacher')), ], - options={ - 'unique_together': {('user', 'token')}, - }, + ), + migrations.AddConstraint( + model_name='school', + constraint=models.CheckConstraint(check=models.Q(('uk_county__isnull', True), ('country', 'UK'), _connector='OR'), name='school__uk_county_is_null_or_country_equals_uk'), + ), + migrations.AddField( + model_name='otpbypasstoken', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='otp_bypass_tokens', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='class', + name='school', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='classes', to='user.school'), + ), + migrations.AddField( + model_name='class', + name='teacher', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='classes', to='user.teacher'), + ), + migrations.AddField( + model_name='authfactor', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_factors', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='user', + name='groups', + field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups'), + ), + migrations.AddField( + model_name='user', + name='student', + field=models.OneToOneField(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='user.student'), + ), + migrations.AddField( + model_name='user', + name='teacher', + field=models.OneToOneField(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='user.teacher'), + ), + migrations.AddField( + model_name='user', + name='user_permissions', + field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'), + ), + migrations.AlterUniqueTogether( + name='sessionauthfactor', + unique_together={('session', 'auth_factor')}, + ), + migrations.AlterUniqueTogether( + name='schoolteacherinvitation', + unique_together={('school', 'teacher')}, + ), + migrations.AlterUniqueTogether( + name='otpbypasstoken', + unique_together={('user', 'token')}, + ), + migrations.AddConstraint( + model_name='class', + constraint=models.CheckConstraint(check=models.Q(('teacher__school', django.db.models.expressions.F('school'))), name='class__teacher_in_school'), + ), + migrations.AlterUniqueTogether( + name='authfactor', + unique_together={('user', 'type')}, + ), + migrations.AddConstraint( + model_name='user', + constraint=models.CheckConstraint(check=models.Q(models.Q(('teacher__isnull', True), ('student__isnull', False)), models.Q(('teacher__isnull', False), ('student__isnull', True)), _connector='OR'), name='user__teacher_is_null_or_student_is_null'), ), ] diff --git a/codeforlife/user/migrations/0002_classstudentjoinrequest.py b/codeforlife/user/migrations/0002_classstudentjoinrequest.py new file mode 100644 index 00000000..a1d58245 --- /dev/null +++ b/codeforlife/user/migrations/0002_classstudentjoinrequest.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.20 on 2023-12-06 16:06 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ClassStudentJoinRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('last_saved_at', models.DateTimeField(auto_now=True, help_text='Record the last time the model was saved. This is used by our data warehouse to know what data was modified since the last scheduled data transfer from the database to the data warehouse.', verbose_name='last saved at')), + ('delete_after', models.DateTimeField(blank=True, help_text="When this data is scheduled for deletion. Set to null if not scheduled for deletion. This is used by our data warehouse to transfer data that's been scheduled for deletion before it's actually deleted. Data will actually be deleted in a CRON job after this delete after.", null=True, verbose_name='delete after')), + ('klass', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_join_requests', to='user.class')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='class_join_requests', to='user.student')), + ], + options={ + 'unique_together': {('klass', 'student')}, + }, + ), + ] diff --git a/codeforlife/user/models/__init__.py b/codeforlife/user/models/__init__.py index b61114af..b03e0491 100644 --- a/codeforlife/user/models/__init__.py +++ b/codeforlife/user/models/__init__.py @@ -1,12 +1,11 @@ -# from .other import * -# from .session import UserSession -# from .teacher_invitation import SchoolTeacherInvitation from .auth_factor import AuthFactor -from .klass import Class # 'class' is a reserved keyword +from .class_student_join_request import ClassStudentJoinRequest +from .klass import Class from .otp_bypass_token import OtpBypassToken from .school import School +from .school_teacher_invitation import SchoolTeacherInvitation from .session import Session from .session_auth_factor import SessionAuthFactor from .student import Student from .teacher import Teacher -from .user import User, UserProfile # TODO: remove UserProfile +from .user import User diff --git a/codeforlife/user/models/auth_factor.py b/codeforlife/user/models/auth_factor.py index bec978e4..e16d4b04 100644 --- a/codeforlife/user/models/auth_factor.py +++ b/codeforlife/user/models/auth_factor.py @@ -1,11 +1,23 @@ +""" +© Ocado Group +Created on 05/12/2023 at 17:47:31(+00:00). + +Auth factor model. +""" + from django.db import models from django.utils.translation import gettext_lazy as _ +from ...models import AbstractModel from . import user -class AuthFactor(models.Model): +class AuthFactor(AbstractModel): + """A user's enabled authentication factors.""" + class Type(models.TextChoices): + """The type of authentication factor.""" + OTP = "otp", _("one-time password") user: "user.User" = models.ForeignKey( @@ -14,10 +26,14 @@ class Type(models.TextChoices): on_delete=models.CASCADE, ) - type = models.TextField(choices=Type.choices) + type = models.TextField( + _("auth factor type"), + choices=Type.choices, + help_text=_("The type of authentication factor."), + ) - class Meta: + class Meta: # pylint: disable=missing-class-docstring unique_together = ["user", "type"] def __str__(self): - return self.type + return str(self.type) diff --git a/codeforlife/user/models/class_student_join_request.py b/codeforlife/user/models/class_student_join_request.py new file mode 100644 index 00000000..6b2b6a56 --- /dev/null +++ b/codeforlife/user/models/class_student_join_request.py @@ -0,0 +1,37 @@ +""" +© Ocado Group +Created on 05/12/2023 at 17:46:22(+00:00). + +Class student join request model. +""" + +from django.db import models + +from ...models import AbstractModel +from . import klass as _class +from . import student as _student + + +class ClassStudentJoinRequest(AbstractModel): + """A request from a student to join a class.""" + + klass: "_class.Class" = models.ForeignKey( + "user.Class", + related_name="student_join_requests", + on_delete=models.CASCADE, + ) + + student: "_student.Student" = models.ForeignKey( + "user.Student", + related_name="class_join_requests", + on_delete=models.CASCADE, + ) + + # created_at = models.DateTimeField( + # _("created at"), + # auto_now_add=True, + # help_text=_("When the teacher was invited to the school."), + # ) + + class Meta: + unique_together = ["klass", "student"] diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index fa7f12f7..e0e0bda4 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -1,102 +1,86 @@ -# from uuid import uuid4 -# from datetime import timedelta - -# from django.db import models -# from django.utils import timezone - -# from .teacher import Teacher - - -# class ClassModelManager(models.Manager): -# def all_members(self, user): -# members = [] -# if hasattr(user, "teacher"): -# members.append(user.teacher) -# if user.teacher.has_school(): -# classes = user.teacher.class_teacher.all() -# for c in classes: -# members.extend(c.students.all()) -# else: -# c = user.student.class_field -# members.append(c.teacher) -# members.extend(c.students.all()) -# return members - -# # Filter out non active classes by default -# def get_queryset(self): -# return super().get_queryset().filter(is_active=True) - - -# class Class(models.Model): -# name = models.CharField(max_length=200) -# teacher = models.ForeignKey( -# Teacher, related_name="class_teacher", on_delete=models.CASCADE -# ) -# access_code = models.CharField(max_length=5, null=True) -# classmates_data_viewable = models.BooleanField(default=False) -# always_accept_requests = models.BooleanField(default=False) -# accept_requests_until = models.DateTimeField(null=True) -# creation_time = models.DateTimeField(default=timezone.now, null=True) -# is_active = models.BooleanField(default=True) -# created_by = models.ForeignKey( -# Teacher, -# null=True, -# blank=True, -# related_name="created_classes", -# on_delete=models.SET_NULL, -# ) - -# objects = ClassModelManager() - -# def __str__(self): -# return self.name - -# @property -# def active_game(self): -# games = self.game_set.filter(game_class=self, is_archived=False) -# if len(games) >= 1: -# assert ( -# len(games) == 1 -# ) # there should NOT be more than one active game -# return games[0] -# return None - -# def has_students(self): -# students = self.students.all() -# return students.count() != 0 - -# def get_requests_message(self): -# if self.always_accept_requests: -# external_requests_message = ( -# "This class is currently set to always accept requests." -# ) -# elif ( -# self.accept_requests_until is not None -# and (self.accept_requests_until - timezone.now()) >= timedelta() -# ): -# external_requests_message = ( -# "This class is accepting external requests until " -# + self.accept_requests_until.strftime("%d-%m-%Y %H:%M") -# + " " -# + timezone.get_current_timezone_name() -# ) -# else: -# external_requests_message = ( -# "This class is not currently accepting external requests." -# ) - -# return external_requests_message - -# def anonymise(self): -# self.name = uuid4().hex -# self.access_code = "" -# self.is_active = False -# self.save() - -# # Remove independent students' requests to join this class -# self.class_request.clear() - -# class Meta(object): -# verbose_name_plural = "classes" - -from common.models import Class +""" +© Ocado Group +Created on 05/12/2023 at 17:44:48(+00:00). + +Class model. + +NOTE: This module has been named "klass" as "class" is a reserved keyword. +""" + +from django.core.validators import MinLengthValidator, RegexValidator +from django.db import models +from django.db.models import F, Q +from django.db.models.query import QuerySet +from django.utils.translation import gettext_lazy as _ + +from ...models import AbstractModel +from . import class_student_join_request as _class_student_join_request +from . import school as _school +from . import student as _student +from . import teacher as _teacher + + +class Class(AbstractModel): + """A collection of students owned by a teacher.""" + + pk: str # type: ignore + students: QuerySet["_student.Student"] + student_join_requests: QuerySet[ + "_class_student_join_request.ClassStudentJoinRequest" + ] + + id = models.CharField( + _("identifier"), + primary_key=True, + editable=False, + max_length=5, + help_text=_("Uniquely identifies a class."), + validators=[ + MinLengthValidator(5), + RegexValidator( + regex=r"^[0-9a-zA-Z]*$", + message="ID must be alphanumeric.", + code="id_not_alphanumeric", + ), + ], + ) + + teacher: "_teacher.Teacher" = models.ForeignKey( + "user.Teacher", + related_name="classes", + on_delete=models.CASCADE, + ) + + school: "_school.School" = models.ForeignKey( + "user.School", + related_name="classes", + on_delete=models.CASCADE, + ) + + read_classmates_data = models.BooleanField( + _("read classmates data"), + default=False, + help_text=_( + "Designates whether students in this class can see their fellow" + " classmates' data." + ), + ) + + accept_requests_until = models.DateTimeField( + _("accept student join requests until"), + null=True, + blank=True, + help_text=_( + "A point in the future until which requests from students to join" + " this class are accepted. Set to null if it's not accepting" + " requests." + ), + ) + + class Meta: + constraints = [ + models.CheckConstraint( + check=Q(teacher__school=F("school")), + name="class__teacher_in_school", + ), + ] diff --git a/codeforlife/user/models/other.py b/codeforlife/user/models/other.py deleted file mode 100644 index f58f7b20..00000000 --- a/codeforlife/user/models/other.py +++ /dev/null @@ -1,65 +0,0 @@ -from django.db import models -from django.utils import timezone - -from .student import Student - - -# TODO: cleanup these other models. - - -# ----------------------------------------------------------------------- -# Below are models used for data tracking and maintenance -# ----------------------------------------------------------------------- -class JoinReleaseStudent(models.Model): - """ - To keep track when a student is released to be independent student or - joins a class to be a school student. - """ - - JOIN = "join" - RELEASE = "release" - - student = models.ForeignKey( - Student, related_name="student", on_delete=models.CASCADE - ) - # either "release" or "join" - action_type = models.CharField(max_length=64) - action_time = models.DateTimeField(default=timezone.now) - - -class DailyActivity(models.Model): - """ - A model to record sets of daily activity. Currently used to record the amount of - student details download clicks, through the CSV and login cards methods, per day. - """ - - date = models.DateField(default=timezone.now) - csv_click_count = models.PositiveIntegerField(default=0) - login_cards_click_count = models.PositiveIntegerField(default=0) - primary_coding_club_downloads = models.PositiveIntegerField(default=0) - python_coding_club_downloads = models.PositiveIntegerField(default=0) - level_control_submits = models.PositiveBigIntegerField(default=0) - teacher_lockout_resets = models.PositiveIntegerField(default=0) - indy_lockout_resets = models.PositiveIntegerField(default=0) - school_student_lockout_resets = models.PositiveIntegerField(default=0) - - class Meta: - verbose_name_plural = "Daily activities" - - def __str__(self): - return f"Activity on {self.date}: CSV clicks: {self.csv_click_count}, login cards clicks: {self.login_cards_click_count}, primary pack downloads: {self.primary_coding_club_downloads}, python pack downloads: {self.python_coding_club_downloads}, level control submits: {self.level_control_submits}, teacher lockout resets: {self.teacher_lockout_resets}, indy lockout resets: {self.indy_lockout_resets}, school student lockout resets: {self.school_student_lockout_resets}" - - -class DynamicElement(models.Model): - """ - This model is meant to allow us to quickly update some elements dynamically on the website without having to - redeploy everytime. For example, if a maintenance banner needs to be added, we check the box in the Django admin - panel, edit the text and it'll show immediately on the website. - """ - - name = models.CharField(max_length=64, unique=True, editable=False) - active = models.BooleanField(default=False) - text = models.TextField(null=True, blank=True) - - def __str__(self) -> str: - return self.name diff --git a/codeforlife/user/models/otp_bypass_token.py b/codeforlife/user/models/otp_bypass_token.py index fbd22ad8..d6d3d45f 100644 --- a/codeforlife/user/models/otp_bypass_token.py +++ b/codeforlife/user/models/otp_bypass_token.py @@ -1,3 +1,10 @@ +""" +© Ocado Group +Created on 05/12/2023 at 17:44:33(+00:00). + +OTP bypass token model. +""" + import typing as t from itertools import groupby @@ -6,10 +13,11 @@ from django.core.validators import MinLengthValidator from django.db import models +from ...models import AbstractModel from . import user -class OtpBypassToken(models.Model): +class OtpBypassToken(AbstractModel): max_count = 10 max_count_validation_error = ValidationError( f"Exceeded max count of {max_count}" diff --git a/codeforlife/user/models/school.py b/codeforlife/user/models/school.py index 969c9411..b7cfd250 100644 --- a/codeforlife/user/models/school.py +++ b/codeforlife/user/models/school.py @@ -1,50 +1,65 @@ -# from uuid import uuid4 - -# from django.db import models -# from django.utils import timezone -# from django_countries.fields import CountryField - - -# class SchoolModelManager(models.Manager): -# # Filter out inactive schools by default -# def get_queryset(self): -# return super().get_queryset().filter(is_active=True) - - -# class School(models.Model): -# name = models.CharField(max_length=200) -# postcode = models.CharField(max_length=10, null=True) -# country = CountryField(blank_label="(select country)") -# creation_time = models.DateTimeField(default=timezone.now, null=True) -# is_active = models.BooleanField(default=True) - -# objects = SchoolModelManager() - -# def __str__(self): -# return self.name - -# def classes(self): -# teachers = self.school_teacher.all() -# if teachers: -# classes = [] -# for teacher in teachers: -# if teacher.class_teacher.all(): -# classes.extend(list(teacher.class_teacher.all())) -# return classes -# return None - -# def admins(self): -# teachers = self.school_teacher.all() -# return ( -# [teacher for teacher in teachers if teacher.is_admin] -# if teachers -# else None -# ) - -# def anonymise(self): -# self.name = uuid4().hex -# self.postcode = "" -# self.is_active = False -# self.save() - -from common.models import School +""" +© Ocado Group +Created on 05/12/2023 at 17:44:05(+00:00). + +School model. +""" + +from django.db import models +from django.db.models import Q +from django.db.models.query import QuerySet +from django.utils.translation import gettext_lazy as _ + +from ...models import AbstractModel +from ...models.fields import Country, UkCounty +from . import klass as _class +from . import school_teacher_invitation as _school_teacher_invitation +from . import student as _student +from . import teacher as _teacher + + +class School(AbstractModel): + """A collection of teachers and students.""" + + teachers: QuerySet["_teacher.Teacher"] + students: QuerySet["_student.Student"] + teacher_invitations: QuerySet[ + "_school_teacher_invitation.SchoolTeacherInvitation" + ] + classes: QuerySet["_class.Class"] + + name = models.CharField( + _("name"), + max_length=200, + unique=True, + help_text=_("The school's name."), + ) + + country = models.TextField( + _("country"), + choices=Country.choices, + null=True, + blank=True, + help_text=_("The school's country."), + ) + + uk_county = models.TextField( + _("united kingdom county"), + choices=UkCounty.choices, + null=True, + blank=True, + help_text=_( + "The school's county within the United Kingdom. This value may only" + " be set if the school's country is set to UK." + ), + ) + + # created_at = models.DateTimeField(auto_now_add=True) # ? + + class Meta: + constraints = [ + models.CheckConstraint( + check=Q(uk_county__isnull=True) | Q(country="UK"), + name="school__uk_county_is_null_or_country_equals_uk", + ), + ] diff --git a/codeforlife/user/models/school_teacher_invitation.py b/codeforlife/user/models/school_teacher_invitation.py new file mode 100644 index 00000000..9b9d9c43 --- /dev/null +++ b/codeforlife/user/models/school_teacher_invitation.py @@ -0,0 +1,44 @@ +""" +© Ocado Group +Created on 05/12/2023 at 17:44:14(+00:00). + +School teacher invitation model. +""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from ...models import AbstractModel +from . import school as _school +from . import teacher as _teacher + + +class SchoolTeacherInvitation(AbstractModel): + """An invitation for a teacher to join a school.""" + + school: "_school.School" = models.ForeignKey( + "user.School", + related_name="teacher_invitations", + on_delete=models.CASCADE, + ) + + teacher: "_teacher.Teacher" = models.ForeignKey( + "user.Teacher", + related_name="school_invitations", + on_delete=models.CASCADE, + ) + + # created_at = models.DateTimeField( + # _("created at"), + # auto_now_add=True, + # help_text=_("When the teacher was invited to the school."), + # ) + + # expires_at = models.DateTimeField() + + class Meta: + unique_together = ["school", "teacher"] + + # @property + # def is_expired(self): + # return self.expires_at < timezone.now() diff --git a/codeforlife/user/models/session.py b/codeforlife/user/models/session.py index 9becb473..92b0acac 100644 --- a/codeforlife/user/models/session.py +++ b/codeforlife/user/models/session.py @@ -1,22 +1,9 @@ -# from django.db import models -# from django.utils import timezone +""" +© Ocado Group +Created on 04/12/2023 at 17:20:33(+00:00). -# from .classroom import Class -# from .school import School -# from .user import User - - -# class UserSession(models.Model): -# user = models.ForeignKey(User, on_delete=models.CASCADE) -# login_time = models.DateTimeField(default=timezone.now) -# school = models.ForeignKey(School, null=True, on_delete=models.SET_NULL) -# class_field = models.ForeignKey(Class, null=True, on_delete=models.SET_NULL) -# login_type = models.CharField( -# max_length=100, null=True -# ) # for student login - -# def __str__(self): -# return f"{self.user} login: {self.login_time} type: {self.login_type}" +Session model and store. +""" import typing as t @@ -64,6 +51,7 @@ class SessionStore(DBStore): 1. creating only one session per user; 2. setting a session's auth factors; 3. clearing a user's expired sessions. + https://docs.djangoproject.com/en/3.2/topics/http/sessions/#example """ diff --git a/codeforlife/user/models/session_auth_factor.py b/codeforlife/user/models/session_auth_factor.py index d7e43e5f..32287f5a 100644 --- a/codeforlife/user/models/session_auth_factor.py +++ b/codeforlife/user/models/session_auth_factor.py @@ -1,3 +1,10 @@ +""" +© Ocado Group +Created on 05/12/2023 at 17:43:52(+00:00). + +Session auth factor model. +""" + from django.db import models from . import auth_factor, session diff --git a/codeforlife/user/models/student.py b/codeforlife/user/models/student.py index 066ae75f..b4e758fa 100644 --- a/codeforlife/user/models/student.py +++ b/codeforlife/user/models/student.py @@ -1,73 +1,110 @@ -# from uuid import uuid4 - -# from django.db import models - -# from .user import User -# from .classroom import Class - - -# class StudentModelManager(models.Manager): -# def get_random_username(self): -# while True: -# random_username = uuid4().hex[:30] # generate a random username -# if not User.objects.filter(username=random_username).exists(): -# return random_username - -# def schoolFactory(self, klass, name, password, login_id=None): -# user = User.objects.create_user( -# username=self.get_random_username(), -# password=password, -# first_name=name, -# ) - -# return Student.objects.create( -# class_field=klass, user=user, login_id=login_id -# ) - -# def independentStudentFactory(self, name, email, password): -# user = User.objects.create_user( -# username=email, email=email, password=password, first_name=name -# ) - -# return Student.objects.create(user=user) - - -# class Student(models.Model): -# class_field = models.ForeignKey( -# Class, -# related_name="students", -# null=True, -# blank=True, -# on_delete=models.CASCADE, -# ) -# # hashed uuid used for the unique direct login url -# login_id = models.CharField(max_length=64, null=True) -# user = models.OneToOneField( -# User, -# related_name="student", -# null=True, -# blank=True, -# on_delete=models.CASCADE, -# ) -# pending_class_request = models.ForeignKey( -# Class, -# related_name="class_request", -# null=True, -# blank=True, -# on_delete=models.SET_NULL, -# ) -# blocked_time = models.DateTimeField(null=True, blank=True) - -# objects = StudentModelManager() - -# def is_independent(self): -# return not self.class_field - -# def __str__(self): -# return f"{self.user.first_name} {self.user.last_name}" - - -# def stripStudentName(name): -# return re.sub("[ \t]+", " ", name.strip()) - -from common.models import Student +""" +© Ocado Group +Created on 05/12/2023 at 17:43:33(+00:00). + +Student model. +""" + +import typing as t + +from django.contrib.auth.hashers import make_password +from django.core.validators import MinLengthValidator +from django.db import models +from django.db.models.query import QuerySet +from django.utils.translation import gettext_lazy as _ + +from ...models import AbstractModel +from . import class_student_join_request as _class_student_join_request +from . import klass as _class +from . import school as _school +from . import user as _user + + +class Student(AbstractModel): + """A user's student profile.""" + + class Manager(models.Manager): # pylint: disable=missing-class-docstring + def create(self, direct_login_key: str, **fields): + return super().create( + **fields, + direct_login_key=make_password(direct_login_key), + ) + + def bulk_create(self, students: t.Iterable["Student"], *args, **kwargs): + for student in students: + student.direct_login_key = make_password( + student.direct_login_key + ) + + return super().bulk_create(students, *args, **kwargs) + + def create_user(self, student: t.Dict[str, t.Any], **fields): + """Create a user with a student profile. + + Args: + user: The user fields. + + Returns: + A student profile. + """ + + return _user.User.objects.create_user( + **fields, + student=self.create(**student), + ) + + def bulk_create_users( + self, + student_users: t.List[t.Tuple["Student", "_user.User"]], + *args, + **kwargs, + ): + students = [student for (student, _) in student_users] + users = [user for (_, user) in student_users] + + students = self.bulk_create(students, *args, **kwargs) + + for student, user in zip(students, users): + user.student = student + + return _user.User.objects.bulk_create(users, *args, **kwargs) + + def make_random_direct_login_key(self): + return _user.User.objects.make_random_password( + length=Student.direct_login_key.max_length + ) + + objects: Manager = Manager() + + user: "_user.User" + class_join_requests: QuerySet[ + "_class_student_join_request.ClassStudentJoinRequest" + ] + + school: "_school.School" = models.OneToOneField( + "user.School", + related_name="students", + null=True, + editable=False, + on_delete=models.CASCADE, + ) + + klass: "_class.Class" = models.ForeignKey( + "user.Class", + related_name="students", + on_delete=models.CASCADE, + ) + + direct_login_key = models.CharField( + _("direct login key"), + unique=True, + max_length=64, + editable=False, + help_text=_( + "A unique key that allows a student to log directly into their" + "account." + ), + validators=[MinLengthValidator(64)], + ) + + # TODO: add meta constraint for school & direct_login_key diff --git a/codeforlife/user/models/teacher.py b/codeforlife/user/models/teacher.py index d53ab4f1..008f07e5 100644 --- a/codeforlife/user/models/teacher.py +++ b/codeforlife/user/models/teacher.py @@ -1,68 +1,60 @@ -# from django.db import models - -# from .user import User -# from .school import School - - -# class TeacherModelManager(models.Manager): -# def factory(self, first_name, last_name, email, password): -# user = User.objects.create_user( -# username=email, -# email=email, -# password=password, -# first_name=first_name, -# last_name=last_name, -# ) - -# return Teacher.objects.create(user=user) - -# # Filter out non active teachers by default -# def get_queryset(self): -# return super().get_queryset().filter(user__is_active=True) - - -# class Teacher(models.Model): -# user = models.OneToOneField( -# User, -# related_name="teacher", -# null=True, -# blank=True, -# on_delete=models.CASCADE, -# ) -# school = models.ForeignKey( -# School, -# related_name="school_teacher", -# null=True, -# blank=True, -# on_delete=models.SET_NULL, -# ) -# is_admin = models.BooleanField(default=False) -# blocked_time = models.DateTimeField(null=True, blank=True) -# invited_by = models.ForeignKey( -# "self", -# related_name="invited_teachers", -# null=True, -# blank=True, -# on_delete=models.SET_NULL, -# ) - -# objects = TeacherModelManager() - -# def teaches(self, userprofile): -# if hasattr(userprofile, "student"): -# student = userprofile.student -# return ( -# not student.is_independent() -# and student.class_field.teacher == self -# ) - -# def has_school(self): -# return self.school is not (None or "") - -# def has_class(self): -# return self.class_teacher.exists() - -# def __str__(self): -# return f"{self.user.first_name} {self.user.last_name}" - -from common.models import Teacher +""" +© Ocado Group +Created on 05/12/2023 at 17:43:14(+00:00). + +Teacher model. +""" + +import typing as t + +from django.db import models +from django.db.models.query import QuerySet +from django.utils.translation import gettext_lazy as _ + +from ...models import AbstractModel +from . import klass as _class +from . import school as _school +from . import school_teacher_invitation as _school_teacher_invitation +from . import user as _user + + +class Teacher(AbstractModel): + """A user's teacher profile.""" + + class Manager(models.Manager): # pylint: disable=missing-class-docstring + def create_user(self, teacher: t.Dict[str, t.Any], **fields): + """Create a user with a teacher profile. + + Args: + user: The user fields. + + Returns: + A teacher profile. + """ + + return _user.User.objects.create_user( + **fields, + teacher=self.create(**teacher), + ) + + objects: Manager = Manager() + + user: "_user.User" + classes: QuerySet["_class.Class"] + school_invitations: QuerySet[ + "_school_teacher_invitation.SchoolTeacherInvitation" + ] + + school: "_school.School" = models.OneToOneField( + "user.School", + related_name="teachers", + null=True, + editable=False, + on_delete=models.CASCADE, + ) + + is_admin = models.BooleanField( + _("is administrator"), + default=False, + help_text=_("Designates if the teacher has admin privileges."), + ) diff --git a/codeforlife/user/models/teacher_invitation.py b/codeforlife/user/models/teacher_invitation.py deleted file mode 100644 index d6c2e9ca..00000000 --- a/codeforlife/user/models/teacher_invitation.py +++ /dev/null @@ -1,47 +0,0 @@ -from uuid import uuid4 - -from django.db import models -from django.utils import timezone - -from .school import School -from .teacher import Teacher - - -class SchoolTeacherInvitationModelManager(models.Manager): - # Filter out inactive invitations by default - def get_queryset(self): - return super().get_queryset().filter(is_active=True) - - -class SchoolTeacherInvitation(models.Model): - token = models.CharField(max_length=32) - school = models.ForeignKey( - School, - related_name="teacher_invitations", - null=True, - on_delete=models.SET_NULL, - ) - from_teacher = models.ForeignKey( - Teacher, - related_name="school_invitations", - null=True, - on_delete=models.SET_NULL, - ) - creation_time = models.DateTimeField(default=timezone.now, null=True) - is_active = models.BooleanField(default=True) - - objects = SchoolTeacherInvitationModelManager() - - @property - def is_expired(self): - return self.expiry < timezone.now() - - def __str__(self): - return f"School teacher invitation for {self.invited_teacher_email} to {self.school.name}" - - def anonymise(self): - self.invited_teacher_first_name = uuid4().hex - self.invited_teacher_last_name = uuid4().hex - self.invited_teacher_email = uuid4().hex - self.is_active = False - self.save() diff --git a/codeforlife/user/models/user.py b/codeforlife/user/models/user.py index 553012ea..b1daee89 100644 --- a/codeforlife/user/models/user.py +++ b/codeforlife/user/models/user.py @@ -1,109 +1,94 @@ -# from datetime import timedelta -# from enum import Enum +""" +© Ocado Group +Created on 04/12/2023 at 17:19:37(+00:00). -# from django.contrib.auth.models import AbstractUser -# from django.contrib.auth.models import UserManager as AbstractUserManager -# from django.db import models -# from django.utils import timezone +User model. +""" - -# class UserManager(AbstractUserManager): -# def create_user(self, username, email=None, password=None, **extra_fields): -# return super().create_user(username, email, password, **extra_fields) - -# def create_superuser( -# self, username, email=None, password=None, **extra_fields -# ): -# return super().create_superuser( -# username, email, password, **extra_fields -# ) - - -# class User(AbstractUser): -# class Type(str, Enum): -# TEACHER = "teacher" -# DEP_STUDENT = "dependent-student" -# INDEP_STUDENT = "independent-student" - -# developer = models.BooleanField(default=False) -# is_verified = models.BooleanField(default=False) - -# objects: UserManager = UserManager() - -# def __str__(self): -# return self.get_full_name() - -# @property -# def joined_recently(self): -# return timezone.now() - timedelta(days=7) <= self.date_joined - -import typing as t - -from common.models import UserProfile -from django.contrib.auth.models import User as _User +from django.contrib.auth.models import AbstractUser, UserManager +from django.db import models +from django.db.models import Q from django.db.models.query import QuerySet from django.utils.translation import gettext_lazy as _ -from . import auth_factor, otp_bypass_token, session -from .student import Student -from .teacher import Teacher - - -class User(_User): - id: int - auth_factors: QuerySet["auth_factor.AuthFactor"] - otp_bypass_tokens: QuerySet["otp_bypass_token.OtpBypassToken"] - session: "session.Session" - userprofile: UserProfile - - class Meta: - proxy = True +from ...models import AbstractModel +from . import auth_factor as _auth_factor +from . import otp_bypass_token as _otp_bypass_token +from . import session as _session +from . import student as _student +from . import teacher as _teacher + + +class User(AbstractUser, AbstractModel): + """A user within the CFL system.""" + + # Fixes bug with object referencing. + objects: UserManager = UserManager() + + session: "_session.Session" + auth_factors: QuerySet["_auth_factor.AuthFactor"] + otp_bypass_tokens: QuerySet["_otp_bypass_token.OtpBypassToken"] + + otp_secret = models.CharField( + _("OTP secret"), + max_length=40, + null=True, + editable=False, + help_text=_("Secret used to generate a OTP."), + ) + + last_otp_for_time = models.DateTimeField( + _("last OTP for-time"), + null=True, + editable=False, + help_text=_( + "Used to prevent replay attacks, where the same OTP is used for" + " different times." + ), + ) + + teacher: "_teacher.Teacher" = models.OneToOneField( + "user.Teacher", + null=True, + editable=False, + on_delete=models.CASCADE, + ) + + student: "_student.Student" = models.OneToOneField( + "user.Student", + null=True, + editable=False, + on_delete=models.CASCADE, + ) + + class Meta: # pylint: disable=missing-class-docstring + constraints = [ + models.CheckConstraint( + check=( + (Q(teacher__isnull=True) & Q(student__isnull=False)) + | (Q(teacher__isnull=False) & Q(student__isnull=True)) + ), + name="user__teacher_is_null_or_student_is_null", + ), + ] @property def is_authenticated(self): - """ - Check if the user has any pending auth factors. - """ + """Check if the user has any pending auth factors.""" try: return not self.session.session_auth_factors.exists() - except session.Session.DoesNotExist: + except _session.Session.DoesNotExist: return False - @property - def student(self) -> t.Optional[Student]: - try: - return self.new_student - except Student.DoesNotExist: - return None - - @property - def teacher(self) -> t.Optional[Teacher]: - try: - return self.new_teacher - except Teacher.DoesNotExist: - return None - - @property - def is_student(self): - return self.student is not None - @property def is_teacher(self): - return self.teacher is not None - - @property - def otp_secret(self): - return self.userprofile.otp_secret + """Check if the user is a teacher.""" - @property - def last_otp_for_time(self): - return self.userprofile.last_otp_for_time + return _teacher.Teacher.objects.filter(user=self).exists() @property - def is_verified(self): - return self.userprofile.is_verified + def is_student(self): + """Check if the user is a student.""" - @property - def aimmo_badges(self): - return self.userprofile.aimmo_badges + return _student.Student.objects.filter(user=self).exists() diff --git a/manage.py b/manage.py index a317e0a8..b374b966 100644 --- a/manage.py +++ b/manage.py @@ -13,10 +13,6 @@ "django.contrib.staticfiles", "django.contrib.sites", "codeforlife.user", - "aimmo", # TODO: remove this - "game", # TODO: remove this - "common", # TODO: remove this - "portal", # TODO: remove this ] MIDDLEWARE = [ @@ -50,6 +46,8 @@ } } +AUTH_USER_MODEL = "user.User" + if __name__ == "__main__": import os import sys