Skip to content

Commit b56a4c9

Browse files
committed
feat(users): add user archiving feature and update tests
1 parent 564b304 commit b56a4c9

71 files changed

Lines changed: 228 additions & 71 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ Pipfile
99
build
1010
.coverage
1111
dist/*.tar.gz
12-
docs/source/reference
1312

1413
# General ignores
1514
*.crt

config/krb5.conf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@
1717
tjhsst.edu = CSL.TJHSST.EDU
1818
.tjhsst.edu = CSL.TJHSST.EDU
1919
csl.tjhsst.edu = CSL.TJHSST.EDU
20-
.csl.tjhsst.edu = CSL.TJHSST.EDU
20+
.csl.tjhsst.edu = CSL.TJHSST.EDU

config/scripts/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,4 @@ A set of scripts to generate somewhat realistic student data for the Ion develop
3030
6. Pull sports schedules
3131

3232
* Run `python manage.py import_sports MONTH` to pull sports schedules for the month.
33-
* Note this is automatically done for you each month.
33+
* Note this is automatically done for you each month.

docs/source/reference_index/apps/users.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ users
1111
api
1212
courses_urls
1313
forms
14+
management.commands.archive_users
1415
management.commands.import_groups
1516
management.commands.lock
1617
models

intranet/apps/dataimport/management/commands/year_cleanup.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ def add_arguments(self, parser):
2222
default=get_senior_graduation_year(),
2323
help="The senior graduation year",
2424
)
25+
user_mode = parser.add_mutually_exclusive_group(required=True)
26+
user_mode.add_argument("--delete-users", action="store_true", dest="delete_users", help="Delete graduated users.")
27+
user_mode.add_argument("--archive-users", action="store_true", dest="archive_users", help="Archive graduated users instead of deleting.")
28+
user_mode.add_argument("--no-user-changes", action="store_true", dest="no_user_changes", help="Do not change users.")
2529

2630
def ask(self, q):
2731
if input(f"{q} [Yy]: ").lower() != "y":
@@ -81,9 +85,16 @@ def handle(self, *args, **options):
8185
if do_run:
8286
self.update_welcome()
8387

84-
self.stdout.write("Deleting graduated users")
85-
if do_run:
86-
self.handle_delete(senior_grad_year=senior_grad_year)
88+
if options["delete_users"]:
89+
self.stdout.write("Deleting graduated users")
90+
if do_run:
91+
self.handle_delete(senior_grad_year=senior_grad_year)
92+
elif options["archive_users"]:
93+
self.stdout.write("Archiving graduated users")
94+
if do_run:
95+
self.handle_archive(senior_grad_year=senior_grad_year)
96+
else:
97+
self.stdout.write("Skipping user delete/archive")
8798

8899
self.stdout.write("Archiving admin comments")
89100
if do_run:
@@ -112,3 +123,10 @@ def handle_delete(self, *, senior_grad_year: int):
112123
usr.user_type = "alum"
113124
usr.save()
114125
self.stdout.write(f"User {usr.username} KEEP")
126+
127+
def handle_archive(self, *, senior_grad_year: int):
128+
users = get_user_model().objects.filter(graduation_year__lt=senior_grad_year).exclude(user_type="alum")
129+
archived_count, already_archived_count = get_user_model().archive_users(users)
130+
self.stdout.write(f"Archived users: {archived_count}")
131+
if already_archived_count:
132+
self.stdout.write(f"Already archived users: {already_archived_count}")

intranet/apps/dataimport/tests.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def test_year_cleanup(self):
1919
out = StringIO()
2020
year = timezone.now().year
2121
turnover_date = datetime(year, 7, 1)
22-
call_command("year_cleanup", stdout=out, senior_grad_year=year + 1)
22+
call_command("year_cleanup", stdout=out, senior_grad_year=year + 1, delete_users=True)
2323
output = [
2424
"In pretend mode.",
2525
"Turnover date set to: {}".format(turnover_date.strftime("%c")),
@@ -52,7 +52,7 @@ def test_actual_year_cleanup(self):
5252
"intranet.apps.dataimport.management.commands.year_cleanup.timezone.now",
5353
return_value=datetime(2020, 6, 20, tzinfo=pytz.timezone("America/New_York")),
5454
) as m:
55-
call_command("year_cleanup", senior_grad_year=2021, run=True, confirm=True)
55+
call_command("year_cleanup", senior_grad_year=2021, run=True, confirm=True, delete_users=True)
5656

5757
m.assert_called()
5858

intranet/apps/users/admin.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1-
from django.contrib import admin
1+
from django.contrib import admin, messages
22

33
from ..users.models import Course, Section, User, UserProperties
44

55

66
@admin.register(User)
77
class UserAdmin(admin.ModelAdmin):
8+
@admin.action(description="Archive selected users")
9+
def archive_users(self, request, queryset):
10+
archived_count, already_archived_count = User.archive_users(queryset)
11+
12+
if archived_count:
13+
self.message_user(request, f"Archived {archived_count} users.")
14+
if already_archived_count:
15+
self.message_user(request, f"{already_archived_count} users already archived.", level=messages.WARNING)
16+
817
# Render is_active using checkmarks or crosses
918
def user_active(self, obj):
1019
return obj.is_active
@@ -42,6 +51,7 @@ def user_active(self, obj):
4251
"nickname",
4352
"student_id",
4453
)
54+
actions = ("archive_users",)
4555

4656

4757
admin.site.register(UserProperties)
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import csv
2+
3+
from django.contrib.auth import get_user_model
4+
from django.core.management.base import BaseCommand, CommandError
5+
6+
7+
class Command(BaseCommand):
8+
help = "Archive users (lock accounts) from a CSV file."
9+
10+
def add_arguments(self, parser):
11+
parser.add_argument(
12+
"--filename",
13+
dest="filename",
14+
type=str,
15+
required=True,
16+
help="Filename to import data from.",
17+
)
18+
parser.add_argument(
19+
"--column-header",
20+
dest="header",
21+
default="username",
22+
type=str,
23+
help="Header associated with the identifier column in the CSV.",
24+
)
25+
parser.add_argument(
26+
"--lookup-field",
27+
dest="lookup_field",
28+
default="username",
29+
type=str,
30+
help="User model field used to match identifiers (ex: username, student_id, email).",
31+
)
32+
parser.add_argument(
33+
"--run",
34+
action="store_true",
35+
dest="run",
36+
help="Actually run.",
37+
)
38+
parser.add_argument(
39+
"--confirm",
40+
action="store_true",
41+
dest="confirm",
42+
help="Skip confirmation prompt (only applies with --run).",
43+
)
44+
45+
def ask(self, q) -> bool:
46+
return input(f"{q} [y/N]: ").strip().lower() == "y"
47+
48+
def read_identifiers(self, filename: str, column_header: str):
49+
identifiers = []
50+
try:
51+
with open(filename, encoding="utf-8") as csvfile:
52+
reader = csv.DictReader(csvfile)
53+
for row in reader:
54+
value = row.get(column_header)
55+
if value:
56+
identifiers.append(value.strip())
57+
except FileNotFoundError as e:
58+
raise CommandError(f"File not found: {filename}") from e
59+
except OSError as e:
60+
raise CommandError(f"Error reading file: {e}") from e
61+
return identifiers
62+
63+
def handle(self, *args, **options):
64+
identifiers = self.read_identifiers(options["filename"], options["header"])
65+
if not identifiers:
66+
self.stdout.write(self.style.WARNING("No identifiers found in the CSV."))
67+
return
68+
69+
total_identifiers = len(identifiers)
70+
unique_identifiers = list(dict.fromkeys(identifiers))
71+
duplicate_count = total_identifiers - len(unique_identifiers)
72+
73+
user_model = get_user_model()
74+
lookup_field = options["lookup_field"]
75+
valid_fields = {field.name for field in user_model._meta.get_fields()}
76+
if lookup_field not in valid_fields:
77+
raise CommandError(f"Invalid lookup field: {lookup_field}")
78+
lookup_key = f"{lookup_field}__in"
79+
80+
users = user_model.objects.filter(**{lookup_key: unique_identifiers})
81+
found_values = set(users.values_list(lookup_field, flat=True))
82+
missing = sorted(value for value in unique_identifiers if value not in found_values)
83+
84+
self.stdout.write(f"Identifiers provided: {total_identifiers}")
85+
self.stdout.write(f"Unique identifiers: {len(unique_identifiers)}")
86+
if duplicate_count:
87+
self.stdout.write(self.style.WARNING(f"Duplicate identifiers: {duplicate_count}"))
88+
self.stdout.write(f"Matched users: {users.count()}")
89+
self.stdout.write(f"Missing identifiers: {len(missing)}")
90+
if missing:
91+
self.stdout.write(self.style.WARNING(str(missing)))
92+
93+
if not options["run"]:
94+
self.stdout.write("Dry run mode.")
95+
return
96+
97+
if not options["confirm"]:
98+
if not self.ask(
99+
"This script will archive users (lock accounts). Ensure that you\n"
100+
"have a properly backed-up copy of the database before proceeding.\n\n"
101+
"Continue?"
102+
):
103+
self.stdout.write("Aborted.")
104+
return
105+
106+
archived_count, already_archived_count = user_model.archive_users(users)
107+
self.stdout.write(self.style.SUCCESS(f"Archived users: {archived_count}"))
108+
if already_archived_count:
109+
self.stdout.write(self.style.WARNING(f"Already archived users: {already_archived_count}"))

intranet/apps/users/models.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,26 @@ def is_active(self) -> bool:
842842

843843
return not self.username.startswith("INVALID_USER") and not self.user_locked
844844

845+
@classmethod
846+
def archive_users(cls, queryset, *, update_admin_comments: bool = False) -> tuple[int, int]:
847+
to_archive = queryset.filter(user_locked=False)
848+
already_archived_count = queryset.filter(user_locked=True).count()
849+
850+
if update_admin_comments:
851+
current_year = timezone.localdate().year
852+
previous_year = current_year - 1
853+
archived_count = 0
854+
for user in to_archive:
855+
user.user_locked = True
856+
if user.admin_comments:
857+
user.admin_comments = f"\n=== {previous_year}-{current_year} comments ===\n{user.admin_comments}"
858+
user.save(update_fields=["user_locked", "admin_comments"])
859+
archived_count += 1
860+
else:
861+
archived_count = to_archive.update(user_locked=True)
862+
863+
return archived_count, already_archived_count
864+
845865
@property
846866
def is_restricted(self) -> bool:
847867
"""Checks if user needs the restricted view of Ion

intranet/static/css/admin.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ div.readonly {
44

55
div.field-request_json {
66
font-family: monospace;
7-
}
7+
}

0 commit comments

Comments
 (0)