Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1 +1 @@
recursive-include inginious-jplag/templates/ *
recursive-include templates/ *
250 changes: 250 additions & 0 deletions __init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2017 Ludovic Taffin

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Copyright (c) 2017 Ludovic Taffin
# Copyright (c) 2017-2026 Université catholique de Louvain

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

# JPLAG plugin for INGInious
import os
import io
import logging
import requests
import tarfile, tempfile
import base64
import zipfile

from flask import request, render_template
from werkzeug.exceptions import NotFound

from inginious.frontend.pages.course_admin.utils import INGIniousAdminPage
from inginious.common.base import load_json_or_yaml
from inginious.frontend.courses import Course
from inginious.frontend.models import Submission
from inginious.client.client_sync import ClientSync

""" A plugin that uses JPlag to detect similar code """

PATH_TO_PLUGIN = os.path.abspath(os.path.dirname(__file__))
PATH_TO_TEMPLATES = os.path.join(PATH_TO_PLUGIN, "templates")


def add_admin_menu(course): # pylint: disable=unused-argument
""" Add a menu for jplag analyze in the administration """
return ('jplagselectpage', '<i class="fa fa-search fa-fw"></i>&nbsp; JPlag')

def get_submission_archive_here(submissions, archive_file):
file_to_put = {}

# Build unique base paths per submission
file_to_put = {}
for submission in submissions:
# generate all paths where the submission must belong
for base_path in submission.username:
path, i = base_path, 1
while path in file_to_put:
path = base_path + "-" + str(i)
i += 1
file_to_put[path] = submission

# Create the final tar.gz
with tarfile.open(fileobj=archive_file, mode="w:gz") as tar:

for base_path, submission in file_to_put.items():

# If the submission has an archive attached
if submission.archive:
# Read FileField contents into memory
archive_bytes = submission.archive.read()
archive_buffer = io.BytesIO(archive_bytes)

# Open the submission's archive
with tarfile.open(fileobj=archive_buffer, mode="r:*") as subtar:
for member in subtar.getmembers():
# Skip directories without content
subfile = subtar.extractfile(member)
if subfile is None:
continue

# Rewrite the path inside the combined archive
member.name = f"{base_path}/archive/{member.name}"

tar.addfile(member, subfile)

# Reset cursor so caller can read it
archive_file.seek(0)
return archive_file


def init(plugin_manager, client, config):
""" Init the plugin """

_logger = logging.getLogger("inginious.webapp.plugins.jplagselectpage")

jplag_languages = {
"Java": "java",
"C": "c",
"C++": "cpp",
"C#": "csharp",
"Python": "python3",
"JavaScript": "javascript",
"TypeScript": "typescript",
"Go": "golang",
"Kotlin": "kotlin",
"R": "rlang",
"Rust": "rust",
"Swift": "swift",
"Scala": "scala",
"LLVM IR": "llvmir",
"Scheme": "scheme",
"EMF Metamodel": "emf",
"EMF Model": "emf-model",
"SCXML": "scxml",
"Text": "text",
# Add multilanguage ?
# remove some alpha languages ?
}


class JPLAGSelectPage(INGIniousAdminPage):

def GET_AUTH(self, courseid):
"""GET REQUEST"""
_logger.info("Starting JPlag selection")
try:
course = Course.get(courseid)
except:
raise NotFound(description=_("Course not found."))

tasks = course.get_tasks()
_logger.info("Rendering task selection")
return render_template("jplag/templates/jplag_select_task.html", template_folder=PATH_TO_TEMPLATES,
course=courseid, tasks=tasks)

class JPLAGPage(INGIniousAdminPage):
""" A JPlag page """

def GET_AUTH(self, courseid, taskid):
"""GET REQUEST"""

subs_qs = Submission.objects(courseid=courseid, taskid=taskid).order_by('submitted_on')
subs = [s.to_mongo().to_dict() for s in subs_qs]
res = {elem['username'][0]: elem for elem in subs}

# res -> dernière soumission par username ?
# qu'en est-il de si on garde la meilleur solution et pas la dernière ?

return render_template("jplag/templates/jplagselector.html", template_folder=PATH_TO_TEMPLATES,
subs=res, languages=jplag_languages)

def POST_AUTH(self, courseid, taskid):
""" POST REQUEST """

client_sync = ClientSync(client)


job_info = { "environment_type": "docker", "environment": "jplag"}

# get all info from the form

selected_submission_ids = request.form.getlist("submissions")
language = request.form.get("language", "java17") # TODO : modify default to None
try:
percentage_raw = request.form.get("percentage", "70")
percentage = max(0, min(100, int(percentage_raw))) # clamp to [0, 100]
except ValueError:
percentage = 70

# TODO : better value check
save_for_next_year = request.form.get("nextyear", "0") == "1"
compare_with_archives = request.form.get("addarchives", "0") == "1"

# TODO : handle files (template, old submissions, exclude)
# pass them as input to the container and them create them in the container at the start of the runfile ?



# TODO : create a zip file byte string made from all submissions archives (with info about the students)

# get the input or archive for the selected submissions
subs = [self.submission_manager.get_submission(x, False) for x in selected_submission_ids]
course = Course.get(courseid)
task = course.get_task(taskid)

# create a temporary directory to store the submissions and their inputs
with tempfile.TemporaryDirectory() as temp_dir:

# for each submission,
for sub in subs:
# get inputs and save them into appropriate files
input = self.submission_manager.get_input_from_submission(sub, only_input=True)
for problem in task.get_problems():
# create a file with the name of the problem and load the input into it
problem_id = problem.get_id()
problem_input = input.get(problem_id, None)
if problem_input is not None:
problem_file_path = os.path.join(temp_dir, f"{sub['username'][0]}/{problem_id}.txt")
# create the directory/file if it doesn't exist
os.makedirs(os.path.dirname(problem_file_path), exist_ok=True)
# TODO : rename after used programming language extension (e.g. .py, .java, .cpp, etc.) -> problem["language"]
# TODO : handle submissions for multiple users (groups)
with open(problem_file_path, "w") as f:
f.write(problem_input)

# TODO : handle files given by student, not only the input
# get the uploaded_files folder and put it in the apropriate place

# zip directory

# Zip the temp_dir into an in-memory buffer
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for dirpath, dirnames, filenames in os.walk(temp_dir):
for filename in filenames:
full_path = os.path.join(dirpath, filename)
# Store relative path inside the zip (strips the temp_dir prefix)
arcname = os.path.relpath(full_path, temp_dir)
zf.write(full_path, arcname)

zip_bytes = zip_buffer.getvalue()


job_input = {
'submissions': {
'filename': 'submissions.zip',
'value': zip_bytes ,
},
'language': language,
'similarity_threshold': percentage,
}
# change variable name in job_input ?

# TODO : detect file types to analyze, run a JPLAG analysis per type (in one job)



result, grade, problems, tests, custom, state, archive, stdout, stderr = client_sync.new_job(0,
job_info=job_info,
inputdata=job_input,
launcher_name="Plugin - JPLAG",
debug=True)




# temporary render. Page will need to be made better
return render_template("jplag/templates/jplagresult.html", template_folder=PATH_TO_TEMPLATES,url="")

plugin_manager.add_page("/jplag/<courseid>/<taskid>", JPLAGPage.as_view("jplagpage"))
plugin_manager.add_page("/jplagselecttask/<courseid>/", JPLAGSelectPage.as_view("jplagselectpage"))
plugin_manager.add_hook('course_admin_menu', add_admin_menu)
plugin_manager.add_template_prefix("jplag", PATH_TO_PLUGIN)
3 changes: 0 additions & 3 deletions config.yaml

This file was deleted.

37 changes: 37 additions & 0 deletions container/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# DOCKER-VERSION 1.1.0

# Environment variables for the JPLAG environment
# JPLAG_LANGUAGE (required) : The programming language to be used for the plagiarism detection. Supported values are available at https://github.com/jplag/JPlag. Be aware of the version used.
# SIM_THRESHOLD : The similarity threshold for the plagiarism detection. Default is 0 (0% similarity).


ARG VERSION=latest
ARG REGISTRY=ghcr.io
FROM ${REGISTRY}/inginious/env-base:${VERSION}

LABEL org.opencontainers.image.source=https://github.com/INGInious/containers
LABEL org.opencontainers.image.description="Single test JPLAG job environment"
LABEL org.inginious.grading.name="jplag"
LABEL org.inginious.grading.advertise="false"


# Install Java SE 21
RUN dnf clean metadata && \
dnf -y upgrade && \
dnf -y install java-21-openjdk-headless


# JPlag 6.2.0, last version compatible with Java 21
ENV JPLAG_VERSION=6.2.0
ENV JPLAG_JAR=/opt/jplag/jplag-${JPLAG_VERSION}-jar-with-dependencies.jar

RUN mkdir -p /opt/jplag && \
curl -L -o "${JPLAG_JAR}" \
"https://github.com/jplag/JPlag/releases/download/v${JPLAG_VERSION}/jplag-${JPLAG_VERSION}-jar-with-dependencies.jar"


RUN mkdir -p /jplag/input /jplag/submissions /jplag/result /jplag/basecode && \
chmod -R 777 /jplag


COPY run.py /course/run.py
Loading