diff --git a/codeforlife/models/__init__.py b/codeforlife/models/__init__.py index 149d66fa..cadd273a 100644 --- a/codeforlife/models/__init__.py +++ b/codeforlife/models/__init__.py @@ -6,8 +6,10 @@ """ import typing as t +from datetime import timedelta from django.db import models +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from .fields import * @@ -16,10 +18,24 @@ class AbstractModel(models.Model): """Base model to be inherited by other models throughout the CFL system.""" + class Manager(models.Manager): + """Custom model manager to support CFL's system's operations.""" + + def delete(self): + """Schedules all objects in the queryset for deletion.""" + + # TODO: only schedule for deletion. + super().delete() + + objects: Manager = Manager() + + # Type hints for Django's runtime-generated fields. id: int pk: int - # https://docs.djangoproject.com/en/3.2/ref/models/fields/#django.db.models.DateField.auto_now + # Default for how long to wait before a model is deleted. + delete_wait = timedelta(days=3) + last_saved_at = models.DateTimeField( _("last saved at"), auto_now=True, @@ -39,12 +55,25 @@ class AbstractModel(models.Model): " 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." + " after this point in time." ), ) class Meta: abstract = True + # pylint: disable-next=arguments-differ + def delete(self, wait: t.Optional[timedelta] = None): + """Schedules the deletion of this model. + + Args: + wait: How long to wait before this model is deleted. If not set, the + class-level default value is used. + """ + + wait = wait or self.delete_wait + self.delete_after = timezone.now() + wait + self.save() + AnyModel = t.TypeVar("AnyModel", bound=AbstractModel) diff --git a/codeforlife/user/migrations/0001_initial.py b/codeforlife/user/migrations/0001_initial.py index 487fc504..c5d93e7b 100644 --- a/codeforlife/user/migrations/0001_initial.py +++ b/codeforlife/user/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 3.2.20 on 2023-12-06 16:05 +# Generated by Django 3.2.20 on 2023-12-07 11:14 +import codeforlife.user.models.school_teacher_invitation from django.conf import settings import django.contrib.auth.models import django.contrib.auth.validators @@ -34,7 +35,7 @@ class Migration(migrations.Migration): ('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')), + ('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 point in time.", 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')), ], @@ -47,7 +48,7 @@ class Migration(migrations.Migration): 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')), + ('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 point in time.", 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')), ], ), @@ -55,10 +56,18 @@ class Migration(migrations.Migration): 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')), + ('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 point in time.", 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_upper_alphanumeric', message='ID must be alphanumeric with upper case characters.', regex='^[0-9A-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')), + ('receive_requests_until', models.DateTimeField(blank=True, help_text="A point in the future until which the class can receive requests from students to join. Set to null if it's not accepting requests.", null=True, verbose_name='accept student join requests until')), + ], + ), + 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 point in time.", null=True, verbose_name='delete after')), ], ), migrations.CreateModel( @@ -66,7 +75,7 @@ class Migration(migrations.Migration): 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')), + ('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 point in time.", null=True, verbose_name='delete after')), ('token', models.CharField(max_length=8, validators=[django.core.validators.MinLengthValidator(8)])), ], ), @@ -75,7 +84,7 @@ class Migration(migrations.Migration): 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')), + ('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 point in time.", 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')), @@ -100,9 +109,9 @@ class Migration(migrations.Migration): 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')), + ('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 point in time.", 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')), + ('school', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teachers', to='user.school')), ], options={ 'abstract': False, @@ -113,14 +122,11 @@ class Migration(migrations.Migration): 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')), - ('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')), + ('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 point in time.", null=True, verbose_name='delete after')), + ('second_password', models.CharField(editable=False, help_text='A unique key that allows a student to log directly into theiraccount.', max_length=64, validators=[django.core.validators.MinLengthValidator(64)], verbose_name='secondary password')), + ('klass', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='students', to='user.class')), + ('school', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='students', to='user.school')), ], - options={ - 'abstract': False, - }, ), migrations.CreateModel( name='SessionAuthFactor', @@ -135,7 +141,8 @@ class Migration(migrations.Migration): 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')), + ('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 point in time.", null=True, verbose_name='delete after')), + ('expires_at', models.DateTimeField(default=codeforlife.user.models.school_teacher_invitation._set_expires_at, help_text='When the teacher was invited to the school.', verbose_name='is expired')), ('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')), ], @@ -149,6 +156,16 @@ class Migration(migrations.Migration): 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='classstudentjoinrequest', + name='klass', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_join_requests', to='user.class'), + ), + migrations.AddField( + model_name='classstudentjoinrequest', + name='student', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='class_join_requests', to='user.student'), + ), migrations.AddField( model_name='class', name='school', @@ -184,6 +201,10 @@ class Migration(migrations.Migration): 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.AddConstraint( + model_name='student', + constraint=models.CheckConstraint(check=models.Q(models.Q(('klass__isnull', True), ('school__isnull', True)), models.Q(('klass__isnull', False), ('school__isnull', False)), _connector='OR'), name='student__school_is_null_and_class_is_null'), + ), migrations.AlterUniqueTogether( name='sessionauthfactor', unique_together={('session', 'auth_factor')}, @@ -196,6 +217,10 @@ class Migration(migrations.Migration): name='otpbypasstoken', unique_together={('user', 'token')}, ), + migrations.AlterUniqueTogether( + name='classstudentjoinrequest', + unique_together={('klass', 'student')}, + ), migrations.AddConstraint( model_name='class', constraint=models.CheckConstraint(check=models.Q(('teacher__school', django.db.models.expressions.F('school'))), name='class__teacher_in_school'), @@ -206,6 +231,6 @@ class Migration(migrations.Migration): ), 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'), + constraint=models.CheckConstraint(check=models.Q(models.Q(('student__isnull', False), ('teacher__isnull', True)), models.Q(('student__isnull', True), ('teacher__isnull', False)), _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 deleted file mode 100644 index a1d58245..00000000 --- a/codeforlife/user/migrations/0002_classstudentjoinrequest.py +++ /dev/null @@ -1,27 +0,0 @@ -# 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/class_student_join_request.py b/codeforlife/user/models/class_student_join_request.py index 6b2b6a56..dc15b06e 100644 --- a/codeforlife/user/models/class_student_join_request.py +++ b/codeforlife/user/models/class_student_join_request.py @@ -12,6 +12,7 @@ from . import student as _student +# TODO: move to portal class ClassStudentJoinRequest(AbstractModel): """A request from a student to join a class.""" @@ -35,3 +36,5 @@ class ClassStudentJoinRequest(AbstractModel): class Meta: unique_together = ["klass", "student"] + # TODO: check student is independent + # assert class is receiving requests diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index e0e0bda4..726c2a12 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -38,9 +38,9 @@ class Class(AbstractModel): validators=[ MinLengthValidator(5), RegexValidator( - regex=r"^[0-9a-zA-Z]*$", - message="ID must be alphanumeric.", - code="id_not_alphanumeric", + regex=r"^[0-9A-Z]*$", + message="ID must be alphanumeric with upper case characters.", + code="id_not_upper_alphanumeric", ), ], ) @@ -66,13 +66,13 @@ class Class(AbstractModel): ), ) - accept_requests_until = models.DateTimeField( + receive_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" + "A point in the future until which the class can receive requests" + " from students to join. Set to null if it's not accepting" " requests." ), ) diff --git a/codeforlife/user/models/school_teacher_invitation.py b/codeforlife/user/models/school_teacher_invitation.py index 9b9d9c43..4826b496 100644 --- a/codeforlife/user/models/school_teacher_invitation.py +++ b/codeforlife/user/models/school_teacher_invitation.py @@ -5,7 +5,10 @@ School teacher invitation model. """ +from datetime import timedelta + from django.db import models +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from ...models import AbstractModel @@ -13,6 +16,11 @@ from . import teacher as _teacher +def _set_expires_at(): + return lambda: timezone.now() + timedelta(days=7) + + +# TODO: move to portal class SchoolTeacherInvitation(AbstractModel): """An invitation for a teacher to join a school.""" @@ -28,17 +36,19 @@ class SchoolTeacherInvitation(AbstractModel): 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() + expires_at = models.DateTimeField( + _("is expired"), + default=_set_expires_at, + help_text=_("When the teacher was invited to the school."), + ) class Meta: unique_together = ["school", "teacher"] - # @property - # def is_expired(self): - # return self.expires_at < timezone.now() + @property + def is_expired(self): + return self.expires_at < timezone.now() + + def refresh(self): + self.expires_at = _set_expires_at() + self.save() diff --git a/codeforlife/user/models/student.py b/codeforlife/user/models/student.py index b4e758fa..2da7e4be 100644 --- a/codeforlife/user/models/student.py +++ b/codeforlife/user/models/student.py @@ -10,6 +10,7 @@ from django.contrib.auth.hashers import make_password from django.core.validators import MinLengthValidator 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 _ @@ -81,7 +82,8 @@ def make_random_direct_login_key(self): "_class_student_join_request.ClassStudentJoinRequest" ] - school: "_school.School" = models.OneToOneField( + # Is this needed or can it be inferred from klass. + school: "_school.School" = models.ForeignKey( "user.School", related_name="students", null=True, @@ -92,19 +94,32 @@ def make_random_direct_login_key(self): klass: "_class.Class" = models.ForeignKey( "user.Class", related_name="students", + null=True, + editable=False, on_delete=models.CASCADE, ) - direct_login_key = models.CharField( - _("direct login key"), - unique=True, - max_length=64, + second_password = models.CharField( # TODO: make nullable + _("secondary password"), + max_length=64, # investigate hash length editable=False, help_text=_( "A unique key that allows a student to log directly into their" - "account." + "account." # TODO ), validators=[MinLengthValidator(64)], ) + # TODO: add direct reference to teacher # TODO: add meta constraint for school & direct_login_key + + class Meta: + constraints = [ + models.CheckConstraint( + check=( + Q(school__isnull=True, klass__isnull=True) + | Q(school__isnull=False, klass__isnull=False) + ), + name="student__school_is_null_and_class_is_null", + ), + ] diff --git a/codeforlife/user/models/teacher.py b/codeforlife/user/models/teacher.py index 008f07e5..a96a47b7 100644 --- a/codeforlife/user/models/teacher.py +++ b/codeforlife/user/models/teacher.py @@ -21,7 +21,8 @@ class Teacher(AbstractModel): """A user's teacher profile.""" - class Manager(models.Manager): # pylint: disable=missing-class-docstring + # pylint: disable-next=missing-class-docstring + class Manager(AbstractModel.Manager): def create_user(self, teacher: t.Dict[str, t.Any], **fields): """Create a user with a teacher profile. @@ -45,12 +46,12 @@ def create_user(self, teacher: t.Dict[str, t.Any], **fields): "_school_teacher_invitation.SchoolTeacherInvitation" ] - school: "_school.School" = models.OneToOneField( + school: "_school.School" = models.ForeignKey( "user.School", related_name="teachers", null=True, editable=False, - on_delete=models.CASCADE, + on_delete=models.SET_NULL, ) is_admin = models.BooleanField( @@ -58,3 +59,5 @@ def create_user(self, teacher: t.Dict[str, t.Any], **fields): default=False, help_text=_("Designates if the teacher has admin privileges."), ) + + # TODO: add direct reference to students diff --git a/codeforlife/user/models/user.py b/codeforlife/user/models/user.py index b1daee89..1a3c7370 100644 --- a/codeforlife/user/models/user.py +++ b/codeforlife/user/models/user.py @@ -22,8 +22,10 @@ class User(AbstractUser, AbstractModel): """A user within the CFL system.""" - # Fixes bug with object referencing. - objects: UserManager = UserManager() + class Manager(UserManager, AbstractModel.Manager): + """Combines the user manager and CFL model manager.""" + + objects: Manager = Manager() session: "_session.Session" auth_factors: QuerySet["_auth_factor.AuthFactor"] @@ -65,8 +67,8 @@ 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)) + Q(teacher__isnull=True, student__isnull=False) + | Q(teacher__isnull=False, student__isnull=True) ), name="user__teacher_is_null_or_student_is_null", ), @@ -80,15 +82,3 @@ def is_authenticated(self): return not self.session.session_auth_factors.exists() except _session.Session.DoesNotExist: return False - - @property - def is_teacher(self): - """Check if the user is a teacher.""" - - return _teacher.Teacher.objects.filter(user=self).exists() - - @property - def is_student(self): - """Check if the user is a student.""" - - return _student.Student.objects.filter(user=self).exists()