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

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions docs/source/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ the pages below:

usage/graders/writing_graders
usage/graders/examples
usage/file-actions
53 changes: 53 additions & 0 deletions docs/source/usage/file-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# File Actions

File actions are Tin's way of running commands on the files of an assignment.

For example, say you have a Java file used in a grader, and you wanted to compile
it into a `.class` file. Instead of compiling the java file locally and uploading
the class file to Tin each time you change it, you can upload the Java file to
Tin and use a file action to compile it to a `.class` file.

## The File Action Marketplace
When viewing a course, there will be a button called "Manage File Actions". This will take you to the
File Action Marketplace, where you can see every file action created across courses, or create your
own file action. We **strongly recommend** checking if a file action that suits your needs can be found
in the marketplace before creating your own.

After adding a file action to your course, you can edit it, copy it, or remove it from your course
by hovering over it.

## File Action Commands
Let's take the previous example of compiling all `.java` files uploaded to Tin.
To do that, we need to:

1. Find all files _ending_ with `.java`
2. Run `javac <space separated filenames>`

To do this, we can create a file action with

- Match type set to "Ends with"
- Match value set to `.java`
- The command set to `javac $FILES`

```{note}
For security reasons, the command does not have the same capabilities
as a full shell. For example, redirections, heredocs, or command substitutions
are not possible. Instead, think of the command is being run in a subprocess.
```

## `$FILE` vs `$FILES`
In some cases, the need may arise to call a command on every
matching file, instead of a space separated list of filenames. In such
a case, one can use `$FILE` instead of `$FILES`.

To illustrate, suppose we had the following directory structure:
```
.
├── Bar.java
├── data.txt
└── Foo.java
```
Then, assuming that the match value is set to ending with `.java`,

- Setting the command to `javac $FILES` would run `javac Bar.java Foo.java`
- Setting the command to `javac $FILE` would first run `javac Bar.java` and after that, `javac Foo.java`
57 changes: 56 additions & 1 deletion tin/apps/assignments/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from django.conf import settings

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

logger = getLogger(__name__)

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


class FileActionForm(forms.ModelForm):
"""A form to create (or edit) a :class:`.FileAction`."""

class Meta:
model = FileAction
fields = [
"name",
"description",
"command",
"match_type",
"match_value",
"case_sensitive_match",
]
widgets = {
"description": forms.Textarea(attrs={"cols": 32, "rows": 2}),
}

def clean(self):
cleaned_data = super().clean()
if cleaned_data is None:
cleaned_data = self.cleaned_data
cmd = cleaned_data.get("command", "")

if "$FILE" in cmd or "$FILES" in cmd:
if not cleaned_data.get("match_type"):
self.add_error("match_type", "required if command uses $FILE or $FILES")
if not cleaned_data.get("match_value"):
self.add_error("match_value", "required if command uses $FILE or $FILES")

return cleaned_data


class ChooseFileActionForm(forms.Form):
"""A form to choose a file action.

.. warning::

This will allow a user to modify any file action,
including file actions that are added to a course the user
is not a teacher in.

This form is primarily intended for use with Javascript,
where the file action id cannot be determined at template rendering
time.
"""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

self.fields["file_action"] = forms.ModelChoiceField(
queryset=FileAction.objects.all(),
widget=forms.HiddenInput(),
)
18 changes: 18 additions & 0 deletions tin/apps/assignments/migrations/0033_fileaction_description.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.15 on 2024-10-14 15:16

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('assignments', '0032_assignment_quiz_description_and_more'),
]

operations = [
migrations.AddField(
model_name='fileaction',
name='description',
field=models.CharField(blank=True, max_length=100),
),
]
16 changes: 14 additions & 2 deletions tin/apps/assignments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import datetime
import logging
import os
import shlex
import subprocess
from typing import Literal

Expand Down Expand Up @@ -588,11 +589,20 @@ 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.

``$FILES`` is expanded to a space separated list of paths that match the filter.

``$FILE`` means the command will be called once with each file that matches the filter.
"""

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

name = models.CharField(max_length=50)
description = models.CharField(max_length=100, blank=True)

courses = models.ManyToManyField(Course, related_name="file_actions")
command = models.CharField(max_length=1024)
Expand All @@ -611,7 +621,9 @@ def __repr__(self):

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

if (
("$FILE" in self.command or "$FILES" in self.command)
Expand Down
66 changes: 66 additions & 0 deletions tin/apps/assignments/tests/test_file_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from __future__ import annotations

from django.urls import reverse

from tin.tests import login, model_to_dict

from ..models import FileAction


@login("teacher")
def test_manage_file_actions_view(client, course, file_action) -> None:
# make sure the view works
url = reverse("assignments:manage_file_actions", args=[course.id])
response = client.get(url)
assert response.status_code == 200

file_action.courses.clear()
response = client.post(url, {"file_action": file_action.id})
assert file_action.courses.filter(id=course.id).exists()


@login("teacher")
def test_create_file_action_view(client, course) -> None:
url = reverse("assignments:create_file_action", args=[course.id])
# make sure the view works normally
response = client.get(url)
assert response.status_code == 200

response = client.post(url, {"name": "Hi", "command": "echo bye"})
assert course.file_actions.count() == 1

response = client.post(url, {"name": "Hi", "command": "echo $FILES"})
assert (
course.file_actions.count() == 1
), f"Creation form should error if $FILES is a command without a match value (got {response})"

file_action = course.file_actions.first()
assert file_action is not None
fa_data = model_to_dict(file_action)

# try copying the data
response = client.post(f"{url}?action={file_action.id}", fa_data | {"copy": True})
assert (
course.file_actions.count() == 2
), "Passing copy as a POST parameter should copy the file action"

# or modifying the original instance
client.post(f"{url}?action={file_action.id}", fa_data | {"name": "New name!"})
file_action.refresh_from_db()
assert file_action.name == "New name!"

response = client.post(f"{url}?copy=1", fa_data | {"copy": True})
assert (
course.file_actions.count() == 3
), f"Passing copy without an action should create a file action (got {response})"


@login("teacher")
def test_delete_file_action_view(client, course, file_action) -> None:
client.post(
f"{reverse('assignments:delete_file_action', args=[course.id])}",
{"file_action": file_action.id},
)
# it should be removed from the course, but should still exist
assert not course.file_actions.filter(id=file_action.id).exists()
assert FileAction.objects.filter(id=file_action.id).exists()
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(
"<int:course_id>/files/actions/manage",
views.manage_file_actions,
name="manage_file_actions",
),
path(
"<int:course_id>/files/actions/choose/new",
views.create_file_action,
name="create_file_action",
),
path(
"<int:course_id>/files/actions/delete/",
views.delete_file_action_view,
name="delete_file_action",
),
path(
"<int:assignment_id>/files/action/<int:action_id>",
views.file_action_view,
Expand Down
Loading
Loading