Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement views for file actions #87

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 100 additions & 1 deletion tin/apps/assignments/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

from collections.abc import Iterable
from logging import getLogger
from typing import Literal, Self

from django import forms
from django.conf import settings
from django.core.validators import ValidationError
from typing_extensions import TypedDict

from ..submissions.models import Submission
from .models import Assignment, Folder, MossResult
from .models import Assignment, Course, FileAction, Folder, MossResult

logger = getLogger(__name__)

Expand Down Expand Up @@ -241,3 +244,99 @@ class Meta:
"name",
]
help_texts = {"name": "Note: Folders are ordered alphabetically."}


class FileActionArgs(TypedDict):
name: str
command: str
match_type: Literal["", "S", "E", "C"]
match_value: str
case_sensitive_match: bool


class FileActionForm(forms.ModelForm):
def __init__(self, user, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)

self.fields["courses"] = forms.ModelMultipleChoiceField(
queryset=Course.objects.filter_editable(user),
required=True,
)

class Meta:
model = FileAction
fields = [
"name",
"command",
"courses",
"match_type",
"match_value",
"case_sensitive_match",
]
help_texts = {
"command": "You can use $FILE to reference the file that matches the below criteria."
}

JAVAC: FileActionArgs = {
"name": "Compile Java Files",
"command": "javac $FILE",
"match_type": "E",
"match_value": "java",
"case_sensitive_match": True,
}

RANDOM_TEXT_FILES: FileActionArgs = {
"name": "Generate text files with random content",
"command": "base64 /dev/urandom | head -c 500 > file.txt",
"match_type": "",
"match_value": "",
"case_sensitive_match": False,
}

# TODO: add others

TEMPLATES: dict[str, FileActionArgs] = {
"javac": JAVAC,
"random_text_files": RANDOM_TEXT_FILES,
}

@classmethod
def from_template(cls, template: str, user) -> Self:
"""Takes in a template name and returns a :class:`.FileActionForm`.

Args:
template: The name of the template to use.
user: ``request.user``, which is needed to filter the courses.
"""
if config := cls.TEMPLATES.get(template):
return cls(user, data=config)
raise ValueError(f"Invalid template: {template!r}")


class ChooseFileActionForm(forms.Form):
def __init__(self, *args, user, **kwargs) -> None:
super().__init__(*args, **kwargs)

choices = [
(k, v["name"]) # type: ignore[arg-type]
for k, v in FileActionForm.TEMPLATES.items()
] + [("custom", "Custom")]

self.fields["actions"] = forms.ChoiceField(
choices=choices,
required=True,
widget=forms.widgets.RadioSelect(),
label="File Action Template",
)

self.fields["courses"] = forms.ModelMultipleChoiceField(
queryset=Course.objects.filter_editable(user),
required=False,
help_text="Which courses to add the file action too. Only required if using a template.",
)

def clean(self):
super().clean()
if self.cleaned_data.get("actions") != "custom" and not self.cleaned_data.get("courses"):
error = ValidationError("Must select courses if using a template", code="invalid")
self.add_error("courses", error)
6 changes: 5 additions & 1 deletion tin/apps/assignments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,11 @@ def run_action(command: list[str]) -> str:


class FileAction(models.Model):
"""Runs a user uploaded script on files uploaded to an assignment."""
"""Runs a user uploaded script on files uploaded to an assignment.

This can also take (fake) environment variables like ``$FILE``/``$FILES``,
which are replaced with their actual value.
"""

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

Expand Down
15 changes: 15 additions & 0 deletions tin/apps/assignments/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@
views.delete_file_view,
name="delete_file",
),
path(
"files/actions/choose",
views.choose_file_action,
name="choose_file_action",
),
path(
"files/actions/choose/new",
views.create_file_action,
name="create_file_action",
),
path(
"files/actions/delete/<int:action_id>",
views.delete_file_action_view,
name="delete_file_action",
),
path(
"<int:assignment_id>/files/action/<int:action_id>",
views.file_action_view,
Expand Down
70 changes: 69 additions & 1 deletion tin/apps/assignments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,16 @@
from ..users.models import User
from .forms import (
AssignmentForm,
ChooseFileActionForm,
FileActionForm,
FileSubmissionForm,
FileUploadForm,
FolderForm,
GraderScriptUploadForm,
MossForm,
TextSubmissionForm,
)
from .models import Assignment, CooldownPeriod, QuizLogMessage
from .models import Assignment, CooldownPeriod, FileAction, QuizLogMessage
from .tasks import run_moss

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -477,6 +479,72 @@ def file_action_view(request, assignment_id, action_id):
return redirect("assignments:manage_files", assignment.id)


@teacher_or_superuser_required
def choose_file_action(request):
if request.method == "POST":
form = ChooseFileActionForm(request.POST, user=request.user)
if form.is_valid():
if form.cleaned_data["actions"] == "custom":
return http.HttpResponseRedirect(reverse("assignments:create_file_action"))

# this should not raise an error because the options are generated from the valid options
file_action_form = FileActionForm.from_template(
form.cleaned_data["actions"],
user=request.user,
)
if not file_action_form.is_valid():
print(file_action_form.errors)
return redirect("courses:index")
else:
form = ChooseFileActionForm(user=request.user)
return render(request, "assignments/choose_file_action.html", {"form": form})


@teacher_or_superuser_required
def create_file_action(request):
"""Creates or edits a :class:`.FileAction`

Args:
request: The request
action_id: The primary key of the :class:`.FileAction`. If not provided,
it will try to create a new :class:`.FileAction`.
"""
if request.method == "POST":
form = FileActionForm(request.user, data=request.POST)

if form.is_valid():
form.save()
return redirect("courses:index")
else:
form = FileActionForm(request.user)

return render(
request,
"assignments/custom_file_action.html",
{
"form": form,
"nav_item": "Create file action",
},
)


@teacher_or_superuser_required
def delete_file_action_view(request, action_id: int):
"""Delete a :class:`.FileAction`

Args:
request: The request
action_id: The primary key of the :class:`.FileAction`
"""
if request.user.is_superuser:
obj = FileAction
else:
obj = FileAction.objects.filter(course__teacher=request.user)
action = get_object_or_404(obj, id=action_id)
action.delete()
return redirect("courses:index")


@teacher_or_superuser_required
def student_submissions_view(request, assignment_id, student_id):
"""See the submissions of a student
Expand Down
42 changes: 42 additions & 0 deletions tin/templates/assignments/choose_file_action.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{% extends "base.html" %}

{% block head %}
<script type="text/javascript">
$(document).ready(function () {
$("#id_courses").selectize();
});
</script>

{% endblock %}

{% block main %}
<h2>Choose a File Action</h2>

{% if form.errors %}
<h3 class="errors">Please correct the errors below.</h3>
<ul class="errors">
{% for field in form %}
{% for error in field.errors %}
<li>{{ field.label }}: {{ error }}</li>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}

<form method="post">
{% csrf_token %}
<div class="tbl">
{% for field in form %}
<div class="tbl-row">
<span class="tbl-cell bold" style="padding-right:5px;min-width:200px;">{{ field.label_tag }}</span>
<span class="tbl-cell form-input">{{ field }}{% if field.help_text %}<br>{{ field.help_text }}{% endif %}</span>
</div>
{% endfor %}
</div>
<input type="submit" value="Select">
</form>

{% endblock %}
44 changes: 44 additions & 0 deletions tin/templates/assignments/custom_file_action.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{% extends "base.html" %}

{% block head %}
<script type="text/javascript">
$(document).ready(function () {
$("#id_courses").selectize();
});
</script>

{% endblock %}

{% block title %}
{% if not action %}
Create a File Action
{% else %}
Edit File Action {{ action.name | title }}
{% endif %}
{% endblock %}

{% block main %}
{% if action %}
<h2>Edit File Action {{ action.name | title }}</h2>
{% else %}
<h2>Create a File Action</h2>
{% endif %}

<form method="post">
{% csrf_token %}
<div class="tbl">
{% for field in form %}
<div class="tbl-row" {% if field.name == "permission" %} id="{{ field.name }}" {% endif %}>
<span class="tbl-cell bold" style="padding-right:5px;min-width:200px;">{{ field.label_tag }}</span>
<span class="tbl-cell form-input">{{ field }}{% if field.help_text %}<br>{{ field.help_text }}{% endif %}</span>
</div>
{% endfor %}
</div>
<input type="submit" value="{% if action %}Save{% else %}Create{% endif %}" />
{% if action %}
<div style="padding: 20px 0px;">
<a href="{% url 'assignments:delete_file_action' action.id %}" style="color:red; font-weight:bold;">Delete</a>
</div>
{% endif %}
</form>
{% endblock main %}
3 changes: 3 additions & 0 deletions tin/templates/assignments/manage_files.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ <h4 style="color:red;">{{ file_errors }}</h4>
<h2 style="border-top:1px solid lightgray;padding-top:15px;">File actions</h2>
{% for action in actions %}
<a class="left tin-btn" href="{% url 'assignments:file_action' assignment.id action.id %}">{{ action.name }}</a>
<span style="font-size:18px"><a href="{% url 'assignments:edit_file_action' action.id %}">⚙️</a></span>
{% empty %}
<p class="italic">No actions available</p>
{% endfor %}
Expand All @@ -64,4 +65,6 @@ <h3>Last action output</h3>
{% endif %}
{% endif %}

<a class="left tin-btn" href="{% url 'assignments:choose_file_action' %}">Create new File Action</a>

{% endblock %}
1 change: 1 addition & 0 deletions tin/templates/courses/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ <h2 class="left">Courses</h2>
<a class="tin-btn" href="{% url 'courses:create' %}">New course</a>
<a class="tin-btn" href="{% url 'submissions:filter' %}">Filter submissions</a>
<a class="tin-btn" href="{% url 'venvs:index' %}">Manage virtual environments</a>
<a class="tin-btn" href="{% url 'assignments:choose_file_action' %}">New File Action</a>
<a href="{% url 'docs:index' %}" class="tin-btn"><i class="fa fa-book"></i> Tin documentation</a>
</div>
</li>
Expand Down
Loading