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 6 commits 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
40 changes: 39 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,41 @@ 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",
]


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),
),
]
11 changes: 10 additions & 1 deletion tin/apps/assignments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,11 +588,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 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(
"<int:course_id>/files/actions/choose",
views.choose_file_action,
name="choose_file_action",
),
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
98 changes: 97 additions & 1 deletion tin/apps/assignments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from django.urls import reverse
from django.utils.text import slugify
from django.utils.timezone import now
from django.views.decorators.http import require_POST

from ... import sandboxing
from ..auth.decorators import login_required, teacher_or_superuser_required
Expand All @@ -25,14 +26,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 +480,99 @@ 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, course_id: int):
"""Choose a file action template."""
course = get_object_or_404(
Course.objects.filter_editable(request.user),
id=course_id,
)

if request.method == "POST":
form = ChooseFileActionForm(request.POST)
if form.is_valid():
file_action = form.cleaned_data["file_action"]
file_action.courses.add(course)
return http.JsonResponse({"success": True})
return http.JsonResponse({"success": False, "errors": form.errors.as_json()}, status=400)

actions = FileAction.objects.exclude(courses=course)
course_actions = course.file_actions.all()
return render(
request,
"assignments/choose_file_action.html",
{
"actions": actions,
"course_actions": course_actions,
"course": course,
"nav_item": "Choose file action",
},
)


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

If the ``GET`` request has a ``action`` parameter,
the view will action as an edit view.

Args:
request: The request
course_id: The primary key of the :class:`.Course`
"""
course = get_object_or_404(Course.objects.filter_editable(request.user), id=course_id)
if (action_id := request.GET.get("action", "")).isdigit():
action = course.file_actions.filter(id=action_id).first()
else:
action = None

if request.method == "POST":
form = FileActionForm(request.POST, instance=action)
if form.is_valid():
action = form.save(commit=False)
if request.POST.get("copy"):
action.pk = None
action._state.adding = True
action.save()
action.courses.add(course)
return redirect("courses:show", course.id)
else:
form = FileActionForm(instance=action)

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


@teacher_or_superuser_required
@require_POST
def delete_file_action_view(request, course_id: int):
"""Removes a :class:`.FileAction` from a :class:`.Course`.

This does NOT permanently delete the :class:`.FileAction`.

Args:
request: The request
course_id: The primary key of the :class:`.Course`
action_id: The primary key of the :class:`.FileAction`
"""
course = get_object_or_404(Course.objects.filter_editable(request.user), id=course_id)
form = ChooseFileActionForm(request.POST)
if form.is_valid():
action = form.cleaned_data["file_action"]
action.courses.remove(course)
return http.JsonResponse({"success": True})
return http.JsonResponse({"success": False, "errors": form.errors.as_json()}, status=400)


@teacher_or_superuser_required
def student_submissions_view(request, assignment_id, student_id):
"""See the submissions of a student
Expand Down
18 changes: 18 additions & 0 deletions tin/static/css/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,12 @@ a:hover {
text-decoration: underline;
}

a.link {
color: blue;
text-decoration: underline;
font-weight: normal;
}

.italic {
font-style: italic;
}
Expand All @@ -243,6 +249,10 @@ a:hover {
text-align: center;
}

.hidden {
visibility: hidden;
}

.tin-btn {
border: 1px solid #cfcfcf;

Expand All @@ -251,6 +261,10 @@ a:hover {
background: linear-gradient(to bottom, #dfdfdf 0%, #cfcfcf 100%);
}

.fake-btn {
cursor: pointer;
}

.form-input > :is(select.selectized, input):not(input[type='checkbox']) {
min-width: 250px;
}
Expand Down Expand Up @@ -331,6 +345,10 @@ code > pre {
color: red;
}

.red {
color: red;
}

h3.errors {
margin-bottom: 0.5em;
}
Expand Down
99 changes: 99 additions & 0 deletions tin/static/css/choose.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
* {
box-sizing: border-box;
}

.multi-part-container {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}

.row {
display: flex;
flex-wrap: wrap;
margin-top: 0;
margin-right: -0.5rem;
margin-left: -0.5rem;
}

.row > * {
max-width: 100%;
padding-right: 0.75rem;
padding-left: 0.75rem;
margin-top: 0;
}

.col {
width: 25%;
display: flex;
flex-direction: column;
flex: 0 0 auto;
padding-top: 10px;
}

@media (max-width: 768px) {
.row {
flex-direction: column;
}

.col {
width: 100%;
}
}

.card {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
word-wrap: break-word;
color: #212529;
background-color: #fff;
background-clip: border-box;
border: 1px solid rgba(0, 0, 0, 0.125);
border-radius: 0.25rem;
box-shadow:
7px 7px 14px #e8e8ea,
-7px -7px 14px #fcfcfe;
}

.card-content {
display: flex;
flex-direction: column;
justify-content: space-between;
flex: 1 1 auto;
padding: 1em 1em;
word-wrap: break-word;
}

.card-topright {
position: absolute;
top: 0;
right: 0;
padding: 10px;
}

.card-title {
font-size: 1.25rem;
margin-top: 0;
margin-bottom: 0.5rem;
line-height: 1.2;
font-weight: 500;
}

.card-description {
margin-top: 0;
margin-bottom: 1rem;
}

.card-btn {
align-self: flex-end;
margin-top: auto;
font-weight: normal;
cursor: pointer;
}

.card-btn:hover {
text-decoration: underline;
}
2 changes: 1 addition & 1 deletion tin/static/css/login.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.btn.btn-ion {
.btn-ion {
text-decoration: none;
color: #484848;
display: inline-block;
Expand Down
Loading
Loading