Skip to content

Commit c326845

Browse files
Implement views for file actions (#87)
This PR adds a marketplace for file actions. <div align="center"> <img src="https://github.com/user-attachments/assets/fce0a1fe-0c7f-4903-b21f-bf2e55f983e0" width=800> </div> <details> <summary> <strong>How it looks on mobile</strong> </summary> <div align="center"> <img src="https://github.com/user-attachments/assets/865898cb-0e2c-4b50-b0c2-0bd2da041707" width=500> </div> </details> ## Changelog * Add a `description` field to `FileAction` * Right now it's a `CharField(max_length=100)`, it should be discussed whether a `TextField` or a `CharField` with a larger length limit is needed. * Add a view for managing file actions * Teachers can add and remove file actions from their course here. * Redirects to a new create/edit view * Add a create/edit view. * If `?copy=1` is passed, it will create a copy of a file action. * Changed file action parsing from `self.command.split(" ")` to `shlex.split` for more intuitive command behavior. * Changes css rule `.btn.btn-ion` to `.btn-ion` * It was confusing if `.btn` did anything, or if you were supposed to do `.btn.tin-btn`, etc. ## TODO - [x] CSS - [x] "Marketplace" for courses - [x] Views - [x] Docs - [x] Tests - [x] Turn trash button into normal remove button - [ ] Improve copy file action Closes #7 --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent edde680 commit c326845

18 files changed

Lines changed: 684 additions & 14 deletions

File tree

docs/source/usage.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ the pages below:
1010
usage/graders/writing_graders
1111
usage/graders/examples
1212
usage/submission-cap
13+
usage/file-actions
1314
```

docs/source/usage/file-actions.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# File Actions
2+
3+
File actions are Tin's way of running commands on the files of an assignment.
4+
5+
For example, say you have a Java file used in a grader, and you wanted to compile
6+
it into a `.class` file. Instead of compiling the java file locally and uploading
7+
the class file to Tin each time you change it, you can upload the Java file to
8+
Tin and use a file action to compile it to a `.class` file.
9+
10+
## The File Action Marketplace
11+
When viewing a course, there will be a button called "Manage File Actions". This will take you to the
12+
File Action Marketplace, where you can see every file action created across courses, or create your
13+
own file action. We **strongly recommend** checking if a file action that suits your needs can be found
14+
in the marketplace before creating your own.
15+
16+
After adding a file action to your course, you can edit it, copy it, or remove it from your course
17+
by hovering over it.
18+
19+
## File Action Commands
20+
Let's take the previous example of compiling all `.java` files uploaded to Tin.
21+
To do that, we need to:
22+
23+
1. Find all files _ending_ with `.java`
24+
2. Run `javac <space separated filenames>`
25+
26+
To do this, we can create a file action with
27+
28+
- Match type set to "Ends with"
29+
- Match value set to `.java`
30+
- The command set to `javac $FILES`
31+
32+
```{note}
33+
For security reasons, the command does not have the same capabilities
34+
as a full shell. For example, redirections, heredocs, or command substitutions
35+
are not possible. Instead, think of the command as being run as a subprocess.
36+
```
37+
38+
## `$FILE` vs `$FILES`
39+
In some cases, the need may arise to call a command on every
40+
matching file, instead of a space separated list of filenames. In such
41+
a case, one can use `$FILE` instead of `$FILES`.
42+
43+
To illustrate, suppose we had the following directory structure:
44+
```
45+
.
46+
├── Bar.java
47+
├── data.txt
48+
└── Foo.java
49+
```
50+
Then, assuming that the match value is set to ending with `.java`,
51+
52+
- Setting the command to `javac $FILES` would run `javac Bar.java Foo.java`
53+
- Setting the command to `javac $FILE` would first run `javac Bar.java` and after that, `javac Foo.java`

tin/apps/assignments/forms.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from django.db.models import Q
88

99
from ..submissions.models import Submission
10-
from .models import Assignment, Folder, Language, MossResult, SubmissionCap
10+
from .models import Assignment, FileAction, Folder, Language, MossResult, SubmissionCap
1111

1212
logger = getLogger(__name__)
1313

@@ -305,3 +305,58 @@ class Meta:
305305
"student": "The student to apply the cap to.",
306306
}
307307
labels = {"submission_cap_after_due": "Submission cap after due date"}
308+
309+
310+
class FileActionForm(forms.ModelForm):
311+
"""A form to create (or edit) a :class:`.FileAction`."""
312+
313+
class Meta:
314+
model = FileAction
315+
fields = [
316+
"name",
317+
"description",
318+
"command",
319+
"match_type",
320+
"match_value",
321+
"case_sensitive_match",
322+
]
323+
widgets = {
324+
"description": forms.Textarea(attrs={"cols": 32, "rows": 2}),
325+
}
326+
327+
def clean(self):
328+
cleaned_data = super().clean()
329+
if cleaned_data is None:
330+
cleaned_data = self.cleaned_data
331+
cmd = cleaned_data.get("command", "")
332+
333+
if "$FILE" in cmd or "$FILES" in cmd:
334+
if not cleaned_data.get("match_type"):
335+
self.add_error("match_type", "required if command uses $FILE or $FILES")
336+
if not cleaned_data.get("match_value"):
337+
self.add_error("match_value", "required if command uses $FILE or $FILES")
338+
339+
return cleaned_data
340+
341+
342+
class ChooseFileActionForm(forms.Form):
343+
"""A form to choose a file action.
344+
345+
.. warning::
346+
347+
This will allow a user to modify any file action,
348+
including file actions that are added to a course the user
349+
is not a teacher in.
350+
351+
This form is primarily intended for use with Javascript,
352+
where the file action id cannot be determined at template rendering
353+
time.
354+
"""
355+
356+
def __init__(self, *args, **kwargs):
357+
super().__init__(*args, **kwargs)
358+
359+
self.fields["file_action"] = forms.ModelChoiceField(
360+
queryset=FileAction.objects.all(),
361+
widget=forms.HiddenInput(),
362+
)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.12 on 2026-03-21 21:29
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('assignments', '0035_remove_submissioncap_unique_type_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='fileaction',
15+
name='description',
16+
field=models.CharField(blank=True, max_length=100),
17+
),
18+
]

tin/apps/assignments/models.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import datetime
22
import logging
33
import os
4+
import shlex
45
import subprocess
56
from pathlib import Path
67
from typing import Literal
@@ -713,11 +714,20 @@ def run_action(command: list[str]) -> str:
713714

714715

715716
class FileAction(models.Model):
716-
"""Runs a user uploaded script on files uploaded to an assignment."""
717+
"""Runs a user uploaded script on files uploaded to an assignment.
718+
719+
This can also take (fake) environment variables like ``$FILE``/``$FILES``,
720+
which are replaced with their actual value.
721+
722+
``$FILES`` is expanded to a space separated list of paths that match the filter.
723+
724+
``$FILE`` means the command will be called once with each file that matches the filter.
725+
"""
717726

718727
MATCH_TYPES = (("S", "Start with"), ("E", "End with"), ("C", "Contain"))
719728

720729
name = models.CharField(max_length=50)
730+
description = models.CharField(max_length=100, blank=True)
721731

722732
courses = models.ManyToManyField(Course, related_name="file_actions")
723733
command = models.CharField(max_length=1024)
@@ -736,7 +746,9 @@ def __repr__(self):
736746

737747
def run(self, assignment: Assignment):
738748
"""Runs the command on the input assignment"""
739-
command = self.command.split(" ")
749+
# shlex.split splits it with POSIX-style shell syntax
750+
# This handles e.g. echo "Hello World" correctly
751+
command = shlex.split(self.command)
740752

741753
if (
742754
("$FILE" in self.command or "$FILES" in self.command)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from __future__ import annotations
2+
3+
from django.urls import reverse
4+
5+
from tin.tests import login, model_to_dict
6+
7+
from ..models import FileAction
8+
9+
10+
@login("teacher")
11+
def test_manage_file_actions_view(client, course, file_action) -> None:
12+
# make sure the view works
13+
url = reverse("assignments:manage_file_actions", args=[course.id])
14+
response = client.get(url)
15+
assert response.status_code == 200
16+
17+
file_action.courses.clear()
18+
response = client.post(url, {"file_action": file_action.id})
19+
assert file_action.courses.filter(id=course.id).exists()
20+
21+
22+
@login("teacher")
23+
def test_create_file_action_view(client, course) -> None:
24+
url = reverse("assignments:create_file_action", args=[course.id])
25+
# make sure the view works normally
26+
response = client.get(url)
27+
assert response.status_code == 200
28+
29+
response = client.post(url, {"name": "Hi", "command": "echo bye"})
30+
assert course.file_actions.count() == 1
31+
32+
response = client.post(url, {"name": "Hi", "command": "echo $FILES"})
33+
assert course.file_actions.count() == 1, (
34+
f"Creation form should error if $FILES is a command without a match value (got {response})"
35+
)
36+
37+
file_action = course.file_actions.first()
38+
assert file_action is not None
39+
fa_data = model_to_dict(file_action)
40+
41+
# try copying the data
42+
response = client.post(f"{url}?action={file_action.id}", fa_data | {"copy": True})
43+
assert course.file_actions.count() == 2, (
44+
"Passing copy as a POST parameter should copy the file action"
45+
)
46+
47+
# or modifying the original instance
48+
client.post(f"{url}?action={file_action.id}", fa_data | {"name": "New name!"})
49+
file_action.refresh_from_db()
50+
assert file_action.name == "New name!"
51+
52+
response = client.post(f"{url}?copy=1", fa_data | {"copy": True})
53+
assert course.file_actions.count() == 3, (
54+
f"Passing copy without an action should create a file action (got {response})"
55+
)
56+
57+
58+
@login("teacher")
59+
def test_delete_file_action_view(client, course, file_action) -> None:
60+
client.post(
61+
f"{reverse('assignments:delete_file_action', args=[course.id])}",
62+
{"file_action": file_action.id},
63+
)
64+
# it should be removed from the course, but should still exist
65+
assert not course.file_actions.filter(id=file_action.id).exists()
66+
assert FileAction.objects.filter(id=file_action.id).exists()

tin/apps/assignments/urls.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,21 @@
2222
views.delete_file_view,
2323
name="delete_file",
2424
),
25+
path(
26+
"<int:course_id>/files/actions/manage",
27+
views.manage_file_actions,
28+
name="manage_file_actions",
29+
),
30+
path(
31+
"<int:course_id>/files/actions/choose/new",
32+
views.create_file_action,
33+
name="create_file_action",
34+
),
35+
path(
36+
"<int:course_id>/files/actions/delete/",
37+
views.delete_file_action_view,
38+
name="delete_file_action",
39+
),
2540
path(
2641
"<int:assignment_id>/files/action/<int:action_id>",
2742
views.file_action_view,

tin/apps/assignments/views.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from ..users.models import User
2727
from .forms import (
2828
AssignmentForm,
29+
ChooseFileActionForm,
30+
FileActionForm,
2931
FileSubmissionForm,
3032
FileUploadForm,
3133
FolderForm,
@@ -35,7 +37,7 @@
3537
SubmissionCapForm,
3638
TextSubmissionForm,
3739
)
38-
from .models import Assignment, CooldownPeriod, QuizLogMessage, SubmissionCap
40+
from .models import Assignment, CooldownPeriod, FileAction, QuizLogMessage, SubmissionCap
3941
from .tasks import run_moss
4042

4143
logger = logging.getLogger(__name__)
@@ -529,6 +531,104 @@ def file_action_view(request, assignment_id, action_id):
529531
return redirect("assignments:manage_files", assignment.id)
530532

531533

534+
@teacher_or_superuser_required
535+
def manage_file_actions(request, course_id: int):
536+
"""Add, remove and edit :class:`.FileAction`s for a :class:`.Course`
537+
538+
Args:
539+
request: The request
540+
course_id: The primary key of the :class:`.Course`
541+
"""
542+
course = get_object_or_404(
543+
Course.objects.filter_editable(request.user),
544+
id=course_id,
545+
)
546+
547+
if request.method == "POST":
548+
form = ChooseFileActionForm(request.POST)
549+
if form.is_valid():
550+
file_action = form.cleaned_data["file_action"]
551+
file_action.courses.add(course)
552+
return http.JsonResponse({"success": True})
553+
return http.JsonResponse({"success": False, "errors": form.errors.as_json()}, status=400)
554+
555+
actions = FileAction.objects.exclude(courses=course)
556+
course_actions = course.file_actions.all()
557+
return render(
558+
request,
559+
"assignments/manage_file_actions.html",
560+
{
561+
"actions": actions,
562+
"course_actions": course_actions,
563+
"course": course,
564+
"nav_item": "Manage file actions",
565+
},
566+
)
567+
568+
569+
@teacher_or_superuser_required
570+
def create_file_action(request, course_id: int):
571+
"""Creates or edits a :class:`.FileAction`
572+
573+
If the ``GET`` request has a ``action`` parameter,
574+
the view will action as an edit view.
575+
576+
Args:
577+
request: The request
578+
course_id: The primary key of the :class:`.Course`
579+
"""
580+
course = get_object_or_404(Course.objects.filter_editable(request.user), id=course_id)
581+
if (action_id := request.GET.get("action", "")).isdigit():
582+
action = get_object_or_404(course.file_actions, id=action_id)
583+
else:
584+
action = None
585+
586+
if request.method == "POST":
587+
form = FileActionForm(request.POST, instance=action)
588+
if form.is_valid():
589+
action = form.save(commit=False)
590+
if request.POST.get("copy"):
591+
action.pk = None
592+
action._state.adding = True
593+
action.save()
594+
action.courses.add(course)
595+
return redirect("courses:show", course.id)
596+
else:
597+
form = FileActionForm(instance=action)
598+
599+
return render(
600+
request,
601+
"assignments/custom_file_action.html",
602+
{
603+
"form": form,
604+
"action": action,
605+
"course": course,
606+
"nav_item": "Create file action",
607+
},
608+
)
609+
610+
611+
@teacher_or_superuser_required
612+
@require_POST
613+
def delete_file_action_view(request, course_id: int):
614+
"""Removes a :class:`.FileAction` from a :class:`.Course`.
615+
616+
This does NOT permanently delete the :class:`.FileAction`.
617+
618+
Args:
619+
request: The request
620+
course_id: The primary key of the :class:`.Course`
621+
action_id: The primary key of the :class:`.FileAction`
622+
"""
623+
course = get_object_or_404(Course.objects.filter_editable(request.user), id=course_id)
624+
form = ChooseFileActionForm(request.POST)
625+
if form.is_valid():
626+
action = form.cleaned_data["file_action"]
627+
action.courses.remove(course)
628+
return http.JsonResponse({"success": True})
629+
return http.JsonResponse({"success": False, "errors": form.errors.as_json()}, status=400)
630+
631+
532632
@teacher_or_superuser_required
533633
def student_submissions_view(request, assignment_id, student_id):
534634
"""See the submissions of a student

0 commit comments

Comments
 (0)