mirror of
https://github.com/Code-For-Groningen/temmies.git
synced 2025-03-16 23:20:16 +01:00
Compare commits
No commits in common. "b819305704c254b72494d0cf19cfd050b12b260f" and "e3d863d7b212583a80bc7691175fe6d685e9c2a0" have entirely different histories.
b819305704
...
e3d863d7b2
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,10 +1,7 @@
|
|||||||
# Config - Testing
|
# Config
|
||||||
config.py
|
config.py
|
||||||
baller.py
|
baller.py
|
||||||
|
|
||||||
#Doc env
|
|
||||||
.docs_env
|
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
@ -3,10 +3,12 @@ version: 2
|
|||||||
|
|
||||||
build:
|
build:
|
||||||
os: ubuntu-22.04
|
os: ubuntu-22.04
|
||||||
|
|
||||||
tools:
|
tools:
|
||||||
python: "3.8"
|
python: "3.12"
|
||||||
|
|
||||||
|
sphinx:
|
||||||
|
configuration: docs/conf.py
|
||||||
|
|
||||||
mkdocs:
|
python:
|
||||||
configuration: mkdocs.yml
|
install:
|
||||||
|
- requirements: docs/requirements.txt
|
30
Folder.py
Normal file
30
Folder.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Module to handle each assignment (most difficult part)
|
||||||
|
|
||||||
|
from Base import Base
|
||||||
|
from Exercise import Exercise
|
||||||
|
from requests import Session
|
||||||
|
|
||||||
|
|
||||||
|
class Assignment(Base):
|
||||||
|
def __init__(self, url:str, name:str, session:Session, parent):
|
||||||
|
super().__init__(url, name, session, parent)
|
||||||
|
self.download = Downloadable(name, session, self)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Assignment {self.name} in course {self.parent.name}"
|
||||||
|
|
||||||
|
def getExercises(self) -> list[Exercise]:
|
||||||
|
# Find li large
|
||||||
|
ul = self.soup.find('ul', class_='round')
|
||||||
|
|
||||||
|
# Turn each li to an exercise instance
|
||||||
|
return self.liLargeToExercises(ul, self.session, self)
|
||||||
|
|
||||||
|
def getExercise(self, name:str) -> Exercise:
|
||||||
|
# Get the exercise
|
||||||
|
r = self.session.get(self.url)
|
||||||
|
soup = BeautifulSoup(r.text, 'lxml')
|
||||||
|
# Search by name
|
||||||
|
exercise = soup.find('a', text=name)
|
||||||
|
# Get the url and transform it into an exercise object
|
||||||
|
return Exercise(url=exercise['href'], name=name, session=self.session, assignment=self)
|
20
Makefile
Normal file
20
Makefile
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Minimal makefile for Sphinx documentation
|
||||||
|
#
|
||||||
|
|
||||||
|
# You can set these variables from the command line, and also
|
||||||
|
# from the environment for the first two.
|
||||||
|
SPHINXOPTS ?=
|
||||||
|
SPHINXBUILD ?= sphinx-build
|
||||||
|
SOURCEDIR = source
|
||||||
|
BUILDDIR = build
|
||||||
|
|
||||||
|
# Put it first so that "make" without argument is like "make help".
|
||||||
|
help:
|
||||||
|
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||||
|
|
||||||
|
.PHONY: help Makefile
|
||||||
|
|
||||||
|
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||||
|
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||||
|
%: Makefile
|
||||||
|
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
@ -1,5 +1,5 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/img/rugemmie.gif" />
|
<img src="images/rugemmie.gif" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
A python library which interacts with themis. Uses bs4. I'll try to end development on a somewhat working state.
|
A python library which interacts with themis. Uses bs4. I'll try to end development on a somewhat working state.
|
||||||
|
@ -1,10 +0,0 @@
|
|||||||
# This project was made with ❤️
|
|
||||||
By [Boyan](https://confest.im) from the student organization [Code for Groningen](https://github.com/Code-For-Groningen/).
|
|
||||||
|
|
||||||
It has **no** affiliation with the [University of Groningen](https://rug.nl).
|
|
||||||
|
|
||||||
## Contact
|
|
||||||
Shoot me an email: boyan(plus)cfg(at)bobokara.com.
|
|
||||||
|
|
||||||
## License
|
|
||||||
This project is licensed under the GPL 3.0 license.
|
|
141
docs/api.md
141
docs/api.md
@ -1,141 +0,0 @@
|
|||||||
# Classes
|
|
||||||
---
|
|
||||||
## `Themis`
|
|
||||||
Creates the initial connection to Themis.
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
```python
|
|
||||||
from temmies.Themis import Themis
|
|
||||||
|
|
||||||
themis = Themis("s-number", "password")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
#### `login()`
|
|
||||||
Logs in to Themis. Runs automatically when the class is initialized.
|
|
||||||
|
|
||||||
#### `getYear(start, end)`
|
|
||||||
Returns an instance of a [`Year`](#year)(academic year) between `start` and `end`.
|
|
||||||
|
|
||||||
```python
|
|
||||||
year = themis.getYear(2023, 2024)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `allYears()`
|
|
||||||
Returns a list of `Year` instances corresponding to all years visible to the user.
|
|
||||||
|
|
||||||
```python
|
|
||||||
years = themis.allYears()
|
|
||||||
```
|
|
||||||
<sub> I don't see why you would need this, but it's here. </sub>
|
|
||||||
|
|
||||||
----
|
|
||||||
|
|
||||||
## `Year`
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
```python
|
|
||||||
year = themis.getYear(2023, 2024)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
#### `getCourse(courseName)`
|
|
||||||
Returns an instance of a [`Course`](#course) with the name `courseName`.
|
|
||||||
|
|
||||||
```python
|
|
||||||
pf = year.getCourse("Programming Fundamentals (for CS)")
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `allCourses()`
|
|
||||||
Returns a list of `Course` instances corresponding to all courses visible to the user in a given `Year`.
|
|
||||||
|
|
||||||
```python
|
|
||||||
courses = year.allCourses()
|
|
||||||
```
|
|
||||||
|
|
||||||
----
|
|
||||||
|
|
||||||
## `Course`
|
|
||||||
### Usage
|
|
||||||
```python
|
|
||||||
|
|
||||||
pf = year.getCourse("Programming Fundamentals (for CS)")
|
|
||||||
print(pf.info) # <- course info attribute
|
|
||||||
assignments = pf.getExerciseGroups()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
#### `getExerciseGroups()`
|
|
||||||
Returns a list of `ExerciseGroup` instances corresponding to all exercise groups visible to the user in a given `Course`.
|
|
||||||
|
|
||||||
```python
|
|
||||||
assignments = pf.getExerciseGroups()
|
|
||||||
```
|
|
||||||
|
|
||||||
## `ExerciseGroup`
|
|
||||||
When this class is initialized, it will automatically fetch the exercise's info, files and test cases(it might be slow, because it indexes the entire course, which I will fix at some point).
|
|
||||||
|
|
||||||
* Both folders and exercises are represented as `ExerciseGroup` instances.
|
|
||||||
* Folders will have the `amExercise` attribute set to `False`.
|
|
||||||
* Folders can have the `downloadFiles` method called on them.
|
|
||||||
* Exercises can have the `submit`, `downloadFiles` and `downloadTCs` method called on them.
|
|
||||||
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
```python
|
|
||||||
pf = year.getCourse("Programming Fundamentals (for CS)")
|
|
||||||
assignments = pf.getExerciseGroups()
|
|
||||||
assignment = assignments[0]
|
|
||||||
print(assignment.amExercise) # <- Exercise or folder attribute
|
|
||||||
print(assignment.files) # <- Downloadable files attribute
|
|
||||||
print(assignment.testCases) # <- Test cases attribute
|
|
||||||
|
|
||||||
print(assignment.folders) # <- If the group contains folders, they will be here
|
|
||||||
print(assignment.exercises) # <- If the group contains exercises, they will be here
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example of folder traversal
|
|
||||||
Let's say we have a folder structure like this:
|
|
||||||
```
|
|
||||||
- Course Name
|
|
||||||
- Week 1
|
|
||||||
- Exercise 1
|
|
||||||
- Exercise 2
|
|
||||||
- Part 1
|
|
||||||
- Part 2
|
|
||||||
- Week 2
|
|
||||||
- Exercise 1
|
|
||||||
- Exercise 2
|
|
||||||
```
|
|
||||||
And we want to get to `Part 2` of `Week 1`'s `Exercise 2`. We would do this:
|
|
||||||
|
|
||||||
```python
|
|
||||||
pf = year.getCourse("Programming Fundamentals (for CS)")
|
|
||||||
assignments = pf.getExerciseGroups()
|
|
||||||
week1 = assignments[0].folders[0]
|
|
||||||
exercise2 = week1.exercises[1]
|
|
||||||
part2 = exercise2.folders[1]
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
#### `downloadFiles(path=".")`
|
|
||||||
Downloads all files in the exercise group to a directory `path`. Defaults to the current directory.
|
|
||||||
|
|
||||||
```python
|
|
||||||
assignment.downloadFiles()
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `downloadTCs(path=".")`
|
|
||||||
Downloads all test cases in the exercise group to a directory `path`. Defaults to the current directory.
|
|
||||||
|
|
||||||
```python
|
|
||||||
assignment.downloadTCs()
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `submit(files)`
|
|
||||||
Submits the files to the exercise group. (This is not implemented yet)
|
|
||||||
|
|
||||||
```python
|
|
||||||
assignment.submit(["file1.py", "file2.py"])
|
|
||||||
```
|
|
27
docs/conf.py
Normal file
27
docs/conf.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Configuration file for the Sphinx documentation builder.
|
||||||
|
#
|
||||||
|
# For the full list of built-in configuration values, see the documentation:
|
||||||
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||||
|
|
||||||
|
# -- Project information -----------------------------------------------------
|
||||||
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||||
|
|
||||||
|
project = 'temmies-docs'
|
||||||
|
copyright = '2024, Boyan K.'
|
||||||
|
author = 'Boyan K.'
|
||||||
|
|
||||||
|
# -- General configuration ---------------------------------------------------
|
||||||
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||||
|
|
||||||
|
extensions = []
|
||||||
|
|
||||||
|
templates_path = ['_templates']
|
||||||
|
exclude_patterns = []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for HTML output -------------------------------------------------
|
||||||
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
||||||
|
|
||||||
|
html_theme = 'sphinx_rtd_theme'
|
||||||
|
html_static_path = ['_static']
|
@ -1,40 +0,0 @@
|
|||||||
# Temmies!
|
|
||||||
<center></center>
|
|
||||||
|
|
||||||
|
|
||||||
## What is this?
|
|
||||||
A python library which interacts with themis. Uses bs4. I'll try to end development on a somewhat working state. [Check out the code](https://github.com/Code-For-Groningen/temmies)
|
|
||||||
|
|
||||||
## Intended Features
|
|
||||||
* Log in
|
|
||||||
* Bulk download of test cases and files~~
|
|
||||||
* Submitting files
|
|
||||||
* Somewhat easy to use API to interact with courses
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
```bash
|
|
||||||
pip install temmies
|
|
||||||
```
|
|
||||||
|
|
||||||
## Example Usage
|
|
||||||
```python
|
|
||||||
import temmies
|
|
||||||
|
|
||||||
# Log in
|
|
||||||
themis = temmies.Themis("s-number", "password")
|
|
||||||
|
|
||||||
# Get a year
|
|
||||||
year = themis.getYear(2023, 2024)
|
|
||||||
|
|
||||||
# Get a course
|
|
||||||
pf = year.getCourse("Programming Fundamentals (for CS)")
|
|
||||||
|
|
||||||
# Get an assignment
|
|
||||||
assignment = pf.getExerciseGroups()
|
|
||||||
|
|
||||||
# Download the files
|
|
||||||
assignment.downloadFiles()
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
21
docs/index.rst
Normal file
21
docs/index.rst
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
.. temmies-docs documentation master file, created by
|
||||||
|
sphinx-quickstart on Tue Feb 13 20:53:28 2024.
|
||||||
|
You can adapt this file completely to your liking, but it should at least
|
||||||
|
contain the root `toctree` directive.
|
||||||
|
|
||||||
|
Temmies!
|
||||||
|
========================================
|
||||||
|
.. image:: https://static.wikia.nocookie.net/undertale/images/7/7b/Temmie_battle_idle.gif
|
||||||
|
:align: center
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
:caption: Contents:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Indices and tables
|
||||||
|
==================
|
||||||
|
|
||||||
|
* :ref:`genindex`
|
||||||
|
* :ref:`modindex`
|
||||||
|
* :ref:`search`
|
1
docs/requirements.txt
Normal file
1
docs/requirements.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
sphinx-rtd-theme==1.3.0
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 190 KiB |
@ -1,6 +0,0 @@
|
|||||||
site_name: Temmies
|
|
||||||
nav:
|
|
||||||
- Temmies: index.md
|
|
||||||
- API Reference: api.md
|
|
||||||
- About: about.md
|
|
||||||
theme: readthedocs
|
|
31
src/Assignment.py
Normal file
31
src/Assignment.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Module to handle each assignment (most difficult part)
|
||||||
|
|
||||||
|
from Downloadable import Downloadable
|
||||||
|
from Base import Base
|
||||||
|
from Exercise import Exercise
|
||||||
|
from requests import Session
|
||||||
|
|
||||||
|
|
||||||
|
class Assignment(Base):
|
||||||
|
def __init__(self, url:str, name:str, session:Session, parent):
|
||||||
|
super().__init__(url, name, session, parent)
|
||||||
|
self.download = Downloadable(name, session, self)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Assignment {self.name} in course {self.parent.name}"
|
||||||
|
|
||||||
|
def getExercises(self) -> list[Exercise]:
|
||||||
|
# Find li large
|
||||||
|
ul = self.soup.find('ul', class_='round')
|
||||||
|
|
||||||
|
# Turn each li to an exercise instance
|
||||||
|
return self.liLargeToExercises(ul, self.session, self)
|
||||||
|
|
||||||
|
def getExercise(self, name:str) -> Exercise:
|
||||||
|
# Get the exercise
|
||||||
|
r = self.session.get(self.url)
|
||||||
|
soup = BeautifulSoup(r.text, 'lxml')
|
||||||
|
# Search by name
|
||||||
|
exercise = soup.find('a', text=name)
|
||||||
|
# Get the url and transform it into an exercise object
|
||||||
|
return Exercise(url=exercise['href'], name=name, session=self.session, assignment=self)
|
72
src/Base.py
Normal file
72
src/Base.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# Noticed there's a similar pattern in the classes, so I'm going to create a base class for them
|
||||||
|
|
||||||
|
# classes that inherit from Base:
|
||||||
|
# - Course
|
||||||
|
# - Assignment
|
||||||
|
# - Exercise
|
||||||
|
from requests import Session
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
class Base:
|
||||||
|
def __init__(self, url:str, name:str, session:Session, parent):
|
||||||
|
self.url = url
|
||||||
|
self.name = name
|
||||||
|
self.session = session
|
||||||
|
self.parent = parent
|
||||||
|
|
||||||
|
def __parseCfgBlock(self, div:BeautifulSoup) -> dict:
|
||||||
|
# We assume that the div is a submission with class "cfg-container round"
|
||||||
|
# Put each key and value in a dictionary
|
||||||
|
# The key is a span with a class "cfg-key"
|
||||||
|
# The value is a span with a class "cfg-val"
|
||||||
|
|
||||||
|
# Get the key and value spans
|
||||||
|
keys = div.find_all('span', class_="cfg-key")
|
||||||
|
values = div.find_all('span', class_="cfg-val")
|
||||||
|
|
||||||
|
# Create a dictionary
|
||||||
|
submission = {}
|
||||||
|
|
||||||
|
# Put each key and value in the dictionary
|
||||||
|
for i in range(len(keys)):
|
||||||
|
submission[keys[i].text] = values[i].text
|
||||||
|
|
||||||
|
return submission
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Fix
|
||||||
|
def getDownloadable(self, soup) -> list:
|
||||||
|
# Make sure we only get the ones that have a link
|
||||||
|
# We parse the cfg and check for the key "Downloads"
|
||||||
|
# Check if downloads are available
|
||||||
|
print(soup)
|
||||||
|
cfg = soup.find('div', class_='cfg-container round')
|
||||||
|
print(cfg)
|
||||||
|
cfg = self.__parseCfgBlock(cfg)
|
||||||
|
# Get the downloads
|
||||||
|
downloads = cfg.get("Downloads", None)
|
||||||
|
if downloads == None:
|
||||||
|
return []
|
||||||
|
# Get the links
|
||||||
|
links = downloads.find_all('a')
|
||||||
|
files = []
|
||||||
|
for link in links:
|
||||||
|
files.append(Base(link['href'], link.text, self.session, self))
|
||||||
|
|
||||||
|
return files
|
||||||
|
|
||||||
|
def getSubmissions(self):
|
||||||
|
# We change the url where course becomes stats
|
||||||
|
url = self.url.replace("course", "stats")
|
||||||
|
r = self.session.get(url)
|
||||||
|
|
||||||
|
# Get each div with class "cfg-container round"
|
||||||
|
soup = BeautifulSoup(r.text, 'lxml')
|
||||||
|
divs = soup.find_all('div', class_="cfg-container round")
|
||||||
|
|
||||||
|
# The first one is an overview, the next ones are the submissions
|
||||||
|
submissions = []
|
||||||
|
for div in divs[1:]:
|
||||||
|
submissions.append(self.__parseCfgBlock(div))
|
||||||
|
return self.__parseCfgBlock(divs[0]), submissions
|
||||||
|
|
@ -3,15 +3,19 @@ from bs4 import BeautifulSoup
|
|||||||
from requests import Session
|
from requests import Session
|
||||||
from ExerciseGroup import ExerciseGroup
|
from ExerciseGroup import ExerciseGroup
|
||||||
import re
|
import re
|
||||||
|
from Base import Base
|
||||||
from exceptions.CourseUnavailable import CourseUnavailable
|
from exceptions.CourseUnavailable import CourseUnavailable
|
||||||
|
|
||||||
class Course:
|
# PROBLEM: This implementation is bad due to inconsistencies in the website
|
||||||
|
# The way we can tell the difference between an assignment and an exercise is by the presence of an a with the class "ass-submitable"
|
||||||
|
# As opposed to folders which contain exercises which are marked with "ass-group"
|
||||||
|
# Therefore, we should take that into consideration and spawn the corresponding Exercise or Assignment class
|
||||||
|
# Naming becomes a bit inconsistent like that as well, as Assignments could be Exercises. Might opt to call the "assignments" "exerciseGroups" or some shit.
|
||||||
|
|
||||||
|
class Course(Base):
|
||||||
# Extend the Base class init
|
# Extend the Base class init
|
||||||
def __init__(self, url:str, name:str, session:Session, parent):
|
def __init__(self, url:str, name:str, session:Session, parent):
|
||||||
self.url = url
|
super().__init__(url, name, session, parent)
|
||||||
self.name = name
|
|
||||||
self.session = session
|
|
||||||
self.parent = parent
|
|
||||||
self.assignments = []
|
self.assignments = []
|
||||||
self.__courseAvailable(self.session.get(self.url))
|
self.__courseAvailable(self.session.get(self.url))
|
||||||
|
|
||||||
@ -22,7 +26,7 @@ class Course:
|
|||||||
# Check if we got an error
|
# Check if we got an error
|
||||||
# print(self.url)
|
# print(self.url)
|
||||||
if "Something went wrong" in r.text:
|
if "Something went wrong" in r.text:
|
||||||
raise CourseUnavailable(message="'Something went wrong'. Course most likely not found. ")
|
raise CourseUnavailable()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def info(self):
|
def info(self):
|
||||||
@ -38,11 +42,4 @@ class Course:
|
|||||||
soup = BeautifulSoup(r.text, 'lxml')
|
soup = BeautifulSoup(r.text, 'lxml')
|
||||||
section = soup.find('div', class_="ass-children")
|
section = soup.find('div', class_="ass-children")
|
||||||
entries = section.find_all('a', href=True)
|
entries = section.find_all('a', href=True)
|
||||||
return [
|
return [ExerciseGroup(f"https://themis.housing.rug.nl{x['href']}", x.text, self.session, self) for x in entries]
|
||||||
ExerciseGroup(
|
|
||||||
f"https://themis.housing.rug.nl{x['href']}",
|
|
||||||
x,
|
|
||||||
self.session,
|
|
||||||
self,
|
|
||||||
)
|
|
||||||
for x in entries]
|
|
45
src/Downloadable.py
Normal file
45
src/Downloadable.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Since we can download files both from the assignment itself and its exercises, this class will handle both
|
||||||
|
|
||||||
|
from requests import Session
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from Base import Base
|
||||||
|
|
||||||
|
class Downloadable(Base):
|
||||||
|
def __init__(self, name, session:Session, parent):
|
||||||
|
self.name = name
|
||||||
|
self.session = session
|
||||||
|
self.parent = parent
|
||||||
|
|
||||||
|
# File handling
|
||||||
|
def __findFile(self, name:str):
|
||||||
|
# Get the file by name
|
||||||
|
for file in self.files:
|
||||||
|
if file.name == name:
|
||||||
|
return file
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def files(self) -> list:
|
||||||
|
# Create a list of files
|
||||||
|
# They are all links in a span with class "cfg-val"
|
||||||
|
r = self.session.get("https://themis.housing.rug.nl" + self.parent.url)
|
||||||
|
soup = BeautifulSoup(r.text, 'lxml')
|
||||||
|
return self.getDownloadable(soup)
|
||||||
|
|
||||||
|
def download(self, filename:str) -> str:
|
||||||
|
# Download the file
|
||||||
|
if filename == None:
|
||||||
|
raise NameError("No filename provided")
|
||||||
|
|
||||||
|
file = self.__findFile(filename)
|
||||||
|
r = self.session.get(file.url, stream=True)
|
||||||
|
with open(file.name, 'wb') as f:
|
||||||
|
for chunk in r.iter_content(chunk_size=1024):
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
return file.name
|
||||||
|
|
||||||
|
def downloadAll(self) -> list[str]:
|
||||||
|
# Download all files
|
||||||
|
return [self.download(file.name) for file in self.files]
|
||||||
|
|
73
src/Exercise.py
Normal file
73
src/Exercise.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
from Base import Base
|
||||||
|
from Downloadable import Downloadable
|
||||||
|
from requests import Session
|
||||||
|
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
|
||||||
|
class Exercise(Base):
|
||||||
|
def __init__(self, url:str, name:str, session:Session, parent):
|
||||||
|
super().__init__()
|
||||||
|
self.download = Downloadable(url, name, session, self)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Exercise {self.name} in assignment {self.parent.name}"
|
||||||
|
|
||||||
|
def getTests(self) -> list[str]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def submit(self, file:str, comment:str) -> str:
|
||||||
|
# Submit a file
|
||||||
|
# The form is in the page with class "cfg-container round"
|
||||||
|
# The form is a POST request to the url with the file and the comment
|
||||||
|
# The url looks like this: https://themis.housing.rug.nl/submit/{year}/{course}/{assignment}/{exercise}?_csrf={session_csrf}&sudo={username}
|
||||||
|
# The current url looks like: https://themis.housing.rug.nl/course/{year}/{course}/{assignment}/{exercise}
|
||||||
|
# The request should contain the contents of the file
|
||||||
|
|
||||||
|
# Get the url
|
||||||
|
url = self.url.replace("course", "submit")
|
||||||
|
# Get the csrf token
|
||||||
|
csrf = self.session.cookies['_csrf']
|
||||||
|
# Get the username
|
||||||
|
username = self.session.cookies['username']
|
||||||
|
|
||||||
|
# Open the file
|
||||||
|
with open(file, 'rb') as f:
|
||||||
|
# Submit the file
|
||||||
|
# After submission it will 302 to the current submission page
|
||||||
|
r = self.session.post(url, files={'file': f}, data={'comment': comment, '_csrf': csrf, 'sudo': username})
|
||||||
|
|
||||||
|
# Follow the redirect and repeatedly send get requests to the page
|
||||||
|
|
||||||
|
# We have a table which represents the test cases. The program should wait until all the test cases are done
|
||||||
|
# The test case is done when all of the elements in the table are not none
|
||||||
|
# The element which showcases this for each <tr class="sub-casetop">
|
||||||
|
# is the class in there. if it is "queued" it is still running.
|
||||||
|
|
||||||
|
# Get the url
|
||||||
|
url = r.url
|
||||||
|
# Get the page
|
||||||
|
r = self.session.get(url)
|
||||||
|
# Get the soup
|
||||||
|
soup = BeautifulSoup(r.text, 'lxml')
|
||||||
|
# Get the table
|
||||||
|
table = soup.find('table')
|
||||||
|
# Get the rows
|
||||||
|
rows = table.find_all('tr', class_='sub-casetop')
|
||||||
|
# Get the status
|
||||||
|
status = [row.find('td', class_='status').text for row in rows]
|
||||||
|
# Wait until all the status are not queued
|
||||||
|
while "queued" in status:
|
||||||
|
# Wait a bit
|
||||||
|
sleep(1)
|
||||||
|
# Get the page
|
||||||
|
r = self.session.get(url)
|
||||||
|
# Get the soup
|
||||||
|
soup = BeautifulSoup(r.text, 'lxml')
|
||||||
|
# Get the table
|
||||||
|
table = soup.find('table')
|
||||||
|
# Get the rows
|
||||||
|
rows = table.find_all('tr', class_='sub-casetop')
|
||||||
|
|
||||||
|
|
||||||
|
pass
|
@ -1,126 +1,39 @@
|
|||||||
from bs4 import BeautifulSoup
|
from Base import Base
|
||||||
from exceptions.IllegalAction import IllegalAction
|
from bs4 import BeautifulSoup\
|
||||||
import re
|
|
||||||
|
|
||||||
class ExerciseGroup():
|
class ExerciseGroup(Base):
|
||||||
def __init__(self, url:str, soup, session, parent):
|
# I can't tell if I'm already an exercise :C
|
||||||
self.url = url
|
|
||||||
self.name = soup.text
|
def __init__(self, url:str, name:str, session, parent):
|
||||||
self.__raw = soup
|
super().__init__(url, name, session, parent)
|
||||||
self.session = session
|
self.exercises = self.getExercises()
|
||||||
self.parent = parent # This is unnecessary, but I'll keep it for now
|
self.folders = self.getFolders()
|
||||||
self.request = self.session.get(self.url)
|
|
||||||
self.soup = BeautifulSoup(self.request.text, 'lxml')
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"ExerciseGroup {self.name} in folder {self.parent.name}"
|
return f"ExerciseGroup {self.name} in course {self.parent.name}"
|
||||||
|
|
||||||
@property
|
def getExercises(self) -> list:
|
||||||
def amExercise(self):
|
r = self.session.get(self.url)
|
||||||
return "ass-submitable" in self.__raw['class']
|
soup = BeautifulSoup(r.text, 'lxml')
|
||||||
|
section = soup.find('div', class_="ass-children")
|
||||||
def submit(self):
|
|
||||||
if not self.amExercise:
|
|
||||||
raise IllegalAction(message="You are submitting to a folder.")
|
|
||||||
|
|
||||||
# Logic for submitting
|
|
||||||
|
|
||||||
# Test cases
|
|
||||||
@property
|
|
||||||
def testCases(self):
|
|
||||||
section = self.soup.find_all('div', class_="subsec round shade")
|
|
||||||
tcs = []
|
|
||||||
for div in section:
|
|
||||||
res = div.find("h4", class_="info")
|
|
||||||
if not res:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if "Test cases" in res.text:
|
|
||||||
for case in div.find_all("div", class_="cfg-line"):
|
|
||||||
if link := case.find("a"):
|
|
||||||
tcs.append(link)
|
|
||||||
return tcs
|
|
||||||
return None
|
|
||||||
|
|
||||||
def downloadTCs(self, path="."):
|
|
||||||
# Logic for downloading test cases(if any)
|
|
||||||
# In a div with class "subsec round shade", where there is an h4 with text "Test cases"
|
|
||||||
if not self.amExercise:
|
|
||||||
raise IllegalAction(message="You are downloading test cases from a folder.")
|
|
||||||
|
|
||||||
for tc in self.testCases:
|
|
||||||
url= f"https://themis.housing.rug.nl{tc['href']}"
|
|
||||||
|
|
||||||
print(f"Downloading {tc.text}")
|
|
||||||
# download the files
|
|
||||||
with open(f"{path}/{tc.text}", "wb") as f:
|
|
||||||
f.write(self.session.get(url).content)
|
|
||||||
|
|
||||||
return self.testCases
|
|
||||||
|
|
||||||
# Files
|
|
||||||
@property
|
|
||||||
def files(self):
|
|
||||||
details = self.soup.find('div', id=lambda x: x and x.startswith('details'))
|
|
||||||
|
|
||||||
cfg_lines = details.find_all('div', class_='cfg-line')
|
|
||||||
|
|
||||||
link_list = []
|
|
||||||
|
|
||||||
for line in cfg_lines:
|
|
||||||
key = line.find('span', class_='cfg-key')
|
|
||||||
|
|
||||||
if key and "Downloads" in key.text.strip():
|
|
||||||
# Extract all links in the cfg-val span
|
|
||||||
links = line.find_all('span', class_='cfg-val')
|
|
||||||
for link in links:
|
|
||||||
a = link.find_all('a')
|
|
||||||
for a in a:
|
|
||||||
link_list.append(a)
|
|
||||||
|
|
||||||
return link_list if link_list else None
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def downloadFiles(self, path="."):
|
|
||||||
for file in self.files:
|
|
||||||
print(f"Downloading file {file.text}")
|
|
||||||
url = f"https://themis.housing.rug.nl{file['href']}"
|
|
||||||
with open(f"{path}/{file.text}", "wb") as f:
|
|
||||||
f.write(self.session.get(url).content)
|
|
||||||
return self.files
|
|
||||||
|
|
||||||
# idea exercises and folders are identical, maybe merge them?
|
|
||||||
@property
|
|
||||||
def exercises(self) -> list:
|
|
||||||
if self.amExercise:
|
|
||||||
return self
|
|
||||||
|
|
||||||
section = self.soup.find('div', class_="ass-children")
|
|
||||||
try:
|
try:
|
||||||
submittables = section.find_all('a', class_="ass-submitable")
|
submittables = section.find_all('a', class_="ass-submitable")
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return [
|
return submittables
|
||||||
ExerciseGroup(f"https://themis.housing.rug.nl{x['href']}",
|
|
||||||
x,
|
|
||||||
self.session,
|
|
||||||
self)
|
|
||||||
for x in submittables]
|
|
||||||
|
|
||||||
@property
|
# Returns a list of names of the folders
|
||||||
def folders(self) -> list:
|
def getFolders(self) -> list:
|
||||||
section = self.soup.find('div', class_="ass-children")
|
r = self.session.get(self.url)
|
||||||
|
soup = BeautifulSoup(r.text, 'lxml')
|
||||||
|
section = soup.find('div', class_="ass-children")
|
||||||
try:
|
try:
|
||||||
folders = section.find_all('a', class_="ass-group")
|
folders = section.find_all('a', class_="ass-group")
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return [
|
return [x.text for x in folders]
|
||||||
ExerciseGroup(f"https://themis.housing.rug.nl{x['href']}",
|
|
||||||
x,
|
def recurse(self, folder:str):
|
||||||
session,
|
print(self.url)
|
||||||
self)
|
|
||||||
for x in folders]
|
|
||||||
|
|
@ -44,6 +44,7 @@ class Themis:
|
|||||||
|
|
||||||
|
|
||||||
def getYear(self, start:int, end:int):
|
def getYear(self, start:int, end:int):
|
||||||
|
# Get the current year
|
||||||
return Year(self.session, self, start, end)
|
return Year(self.session, self, start, end)
|
||||||
|
|
||||||
def allYears(self):
|
def allYears(self):
|
||||||
|
@ -18,7 +18,7 @@ class Year:
|
|||||||
return f"https://themis.housing.rug.nl/course/{self.start}-{self.year}"
|
return f"https://themis.housing.rug.nl/course/{self.start}-{self.year}"
|
||||||
|
|
||||||
# Method to get the courses of the year
|
# Method to get the courses of the year
|
||||||
def allCourses(self, errors:bool=False) -> list[Course]:
|
def getCourses(self, errors:bool=False) -> list[Course]:
|
||||||
# lis in a big ul
|
# lis in a big ul
|
||||||
r = self.session.get(self.url)
|
r = self.session.get(self.url)
|
||||||
soup = BeautifulSoup(r.text, 'lxml')
|
soup = BeautifulSoup(r.text, 'lxml')
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
class CourseUnavailable(Exception):
|
class CourseUnavailable(Exception):
|
||||||
def __init__(self, message:str=""):
|
def __init__(self, message:str="Error in course"):
|
||||||
self.message = "Course Error: " + message
|
self.message = message
|
||||||
super().__init__(self.message)
|
super().__init__(self.message)
|
@ -1,4 +0,0 @@
|
|||||||
class IllegalAction(Exception):
|
|
||||||
def __init__(self, message:str=""):
|
|
||||||
self.message = "Illegal action: " + message
|
|
||||||
super().__init__(self.message)
|
|
Loading…
x
Reference in New Issue
Block a user