diff --git a/.gitignore b/.gitignore index f52b969..d17d8db 100644 --- a/.gitignore +++ b/.gitignore @@ -257,3 +257,5 @@ dist yuoshi.zip .webpack.env + +.vscode diff --git a/.webpack.env.example b/.webpack.env.example index 76b5958..f2dbf79 100644 --- a/.webpack.env.example +++ b/.webpack.env.example @@ -1,4 +1,5 @@ -# copy this file to .webpack.env and change settings there +# copy this file to .webpack.env and change settings there. STUDIP_URL=http://localhost:8123 PLUGIN_PATH=/plugins_packages/xyng/Yuoshi API_PATH=/jsonapi.php/v1 + diff --git a/Yuoshi.php b/Yuoshi.php index f3e7f1a..4761b4b 100644 --- a/Yuoshi.php +++ b/Yuoshi.php @@ -10,9 +10,12 @@ use Xyng\Yuoshi\Api\Controller\TaskContentSolutionsController; use Xyng\Yuoshi\Api\Controller\TasksController; use Xyng\Yuoshi\Api\Controller\TaskSolutionsController; +use Xyng\Yuoshi\Api\Controller\StationController; -class Yuoshi extends StudIPPlugin implements StandardPlugin, SystemPlugin, JsonApiPlugin { - public function __construct() { +class Yuoshi extends StudIPPlugin implements StandardPlugin, SystemPlugin, JsonApiPlugin +{ + public function __construct() + { parent::__construct(); // Enable this when changing tables. @@ -36,7 +39,7 @@ public function __construct() { * * @return object template object to render or NULL */ - function getInfoTemplate($course_id) + public function getInfoTemplate($course_id) { // TODO: Implement getInfoTemplate() method. } @@ -57,7 +60,7 @@ function getInfoTemplate($course_id) * * @return object navigation item to render or NULL */ - function getIconNavigation($course_id, $last_visit, $user_id) + public function getIconNavigation($course_id, $last_visit, $user_id) { // TODO: Implement getIconNavigation() method. } @@ -76,7 +79,7 @@ function getIconNavigation($course_id, $last_visit, $user_id) * * @return array navigation item to render or NULL */ - function getTabNavigation($course_id) + public function getTabNavigation($course_id) { return [ 'yuoshi' => new Navigation(_('yUOShi'), PluginEngine::getURL($this, array(), 'index')) @@ -89,17 +92,26 @@ function getTabNavigation($course_id) public function registerAuthenticatedRoutes(\Slim\App $app) { $app->get('/courses/{id}/packages', PackagesController::class . ':index'); - $app->post('/courses/{id}/packages', PackagesController::class . ':create'); $app->get('/packages', PackagesController::class . ':index'); - $app->post('/packages', PackagesController::class . ':create'); + $app->get('/packages/{id}', PackagesController::class . ':show'); $app->get('/packages/export/{package_id}', PackageImportController::class . ':export'); + $app->get('/packages/{id}/tasks', TasksController::class . ':index'); + $app->get('/packages/{id}/nextTask', TasksController::class . ':nextTask'); + $app->get('/packages/{id}/stations', StationController::class . ':index'); + $app->post('/packages', PackagesController::class . ':create'); $app->post('/packages/import/{course_id}', PackageImportController::class . ':import'); - $app->get('/packages/{id}', PackagesController::class . ':show'); + $app->post('/courses/{id}/packages', PackagesController::class . ':create'); $app->patch('/packages/{id}', PackagesController::class . ':update'); $app->delete('/packages/{package_id}', PackagesController::class . ':delete'); - $app->get('/packages/{id}/tasks', TasksController::class . ':index'); - $app->get('/packages/{id}/nextTask', TasksController::class . ':nextTask'); + + $app->get('/stations', StationController::class . ':index'); + $app->get('/stations/{id}', StationController::class . ':show'); + $app->get('/stations/{id}/tasks', StationController::class . ':show'); + + $app->delete('/stations/{station_id}', StationController::class . ':delete'); + $app->post('/stations', StationController::class . ':create'); + $app->get('/stations/{id}/nextTask', TasksController::class . ':nextTask'); $app->get('/tasks', TasksController::class . ':index'); $app->post('/tasks', TasksController::class . ':create'); @@ -111,7 +123,6 @@ public function registerAuthenticatedRoutes(\Slim\App $app) $app->patch('/tasks/{task_id}/contents/{content_id}', TaskContentsController::class . ':update'); $app->get('/tasks/{task_id}/task_solutions', TaskSolutionsController::class . ':index'); $app->get('/tasks/{task_id}/current_task_solution', TaskSolutionsController::class . ':getCurrentSolution'); - $app->get('/task_solutions', TaskSolutionsController::class . ':index'); $app->get('/task_solutions/{task_solution_id}', TaskSolutionsController::class . ':show'); $app->patch('/task_solutions/{task_solution_id}', TaskSolutionsController::class . ':update'); @@ -166,7 +177,9 @@ public function registerSchemas(): array { return [ \Xyng\Yuoshi\Model\UserPackageProgress::class => \Xyng\Yuoshi\Api\Schema\UserPackageProgress::class, + \Xyng\Yuoshi\Model\UserStationProgress::class => \Xyng\Yuoshi\Api\Schema\UserStationProgress::class, \Xyng\Yuoshi\Model\Packages::class => \Xyng\Yuoshi\Api\Schema\Packages::class, + \Xyng\Yuoshi\Model\Stations::class => \Xyng\Yuoshi\Api\Schema\Stations::class, \Xyng\Yuoshi\Model\Tasks::class => \Xyng\Yuoshi\Api\Schema\Tasks::class, \Xyng\Yuoshi\Model\TaskContents::class => \Xyng\Yuoshi\Api\Schema\Contents::class, \Xyng\Yuoshi\Model\TaskContentQuests::class => \Xyng\Yuoshi\Api\Schema\Quests::class, @@ -193,12 +206,14 @@ public function perform($unconsumedPath) $dispatcher->dispatch($unconsumedPath); } - public static function onEnable($pluginId) { + public static function onEnable($pluginId) + { // enable nobody role by default \RolePersistence::assignPluginRoles($pluginId, array(7)); } - private function loadAssets($keys = []) { + private function loadAssets($keys = []) + { // get webpack manifest $path = __DIR__ . DIRECTORY_SEPARATOR . 'dist' . DIRECTORY_SEPARATOR . 'manifest.json'; $json = file_get_contents($path); diff --git a/app/contexts/CU b/app/contexts/CU new file mode 100644 index 0000000..e69de29 diff --git a/app/contexts/CurrentStationContext.tsx b/app/contexts/CurrentStationContext.tsx new file mode 100644 index 0000000..9fd9766 --- /dev/null +++ b/app/contexts/CurrentStationContext.tsx @@ -0,0 +1,61 @@ +import React, { createContext, useContext } from "react" + +import useGetModelFromListOrFetch from "../helpers/useGetModelFromListOrFetch" +import Station from "../models/Station" + +import { useStationContext } from "./StationContext" + +interface CurrentStationContextInterface { + station: Station + updateStation: (station: Station, reload?: boolean) => Promise +} +const CurrentStationContext = createContext( + null +) + +export const useCurrentStationContext = () => { + const ctx = useContext(CurrentStationContext) + + if (ctx === null) { + throw new Error("No CurrentStationContext available.") + } + + return ctx +} + +const fetchStation = async (stationId: string) => { + const station = ( + await Station.with("station").find(stationId) + ).getData() as Station | null + + if (!station) { + throw new Error("Station not found") + } + + return station +} + +export const CurrentStationContextProvider: React.FC<{ + stationId?: string +}> = ({ children, stationId }) => { + const { station, updateStation, reloadStations } = useStationContext() + const { entityData, updateEntity } = useGetModelFromListOrFetch( + stationId, + station, + stationId ? [stationId, "stationId"] : null, + fetchStation, + updateStation, + reloadStations + ) + + const ctx = { + station: entityData, + updateStation: updateEntity, + } + + return ( + + {children} + + ) +} diff --git a/app/contexts/StationContext.tsx b/app/contexts/StationContext.tsx new file mode 100644 index 0000000..beb65eb --- /dev/null +++ b/app/contexts/StationContext.tsx @@ -0,0 +1,83 @@ +import React, { createContext, useCallback, useContext, useMemo } from "react" +import { PluralResponse } from "coloquent" +import useSWR, { responseInterface } from "swr" + +import Station from "../models/Station" +import updateModelList from "../helpers/updateModelList" + +import { useCurrentPackageContext } from "./CurrentPackageContext" +interface StationContextInterface { + station: Station[] + updateStation: (updated: Station, reload?: boolean) => Promise + reloadStations: () => Promise + mutate: responseInterface["mutate"] +} +const StationContext = createContext(null) + +export const useStationContext = () => { + const ctx = useContext(StationContext) + + if (ctx === null) { + throw new Error("No StationContextProvider available.") + } + + return ctx +} + +const fetchStationsForPackage = (byUser: boolean) => async ( + packageId: string +): Promise => { + let query = Station.where("package", packageId) + + if (byUser) { + query = query.with("stationUserProgress.user") + } else { + query = query.with("stationTotalProgress") + } + + const stationItem = (await query.get()) as PluralResponse + + return stationItem.getData() as Station[] +} + +export const StationContextProvider: React.FC<{ + byUser?: boolean +}> = ({ children, byUser }) => { + const { currentPackage } = useCurrentPackageContext() + const cacheKey = useMemo( + () => (byUser ? "package/stations_by_user" : "package/stations"), + [byUser] + ) + const fetch = useMemo(() => fetchStationsForPackage(!!byUser), [byUser]) + + const { data, mutate, revalidate } = useSWR( + () => + currentPackage.getApiId() + ? [currentPackage.getApiId(), cacheKey] + : null, + fetch, + { suspense: true } + ) + + const updateStation = useCallback( + async (updatedStation: Station, reload: boolean = false) => { + await mutate(updateModelList(updatedStation), reload) + }, + [mutate] + ) + + const ctx = { + station: (data as Station[]).sort((a, b) => { + return a.getSort() - b.getSort() + }), + updateStation, + reloadStations: revalidate, + mutate, + } + + return ( + + {children} + + ) +} diff --git a/app/contexts/TasksContext.tsx b/app/contexts/TasksContext.tsx index 1db9733..3c638c5 100644 --- a/app/contexts/TasksContext.tsx +++ b/app/contexts/TasksContext.tsx @@ -5,7 +5,7 @@ import useSWR, { responseInterface } from "swr" import Task from "../models/Task" import updateModelList from "../helpers/updateModelList" -import { useCurrentPackageContext } from "./CurrentPackageContext" +import { useCurrentStationContext } from "./CurrentStationContext" interface TasksContextInterface { tasks: Task[] @@ -25,20 +25,20 @@ export const useTasksContext = () => { return ctx } -const fetchTasksForPackage = async (packageId: string): Promise => { - const packageItem = (await Task.where( - "package", - packageId +const fetchTasksForStations = async (stationId: string): Promise => { + const stationItem = (await Task.where( + "station", + stationId ).get()) as PluralResponse - return packageItem.getData() as Task[] + return stationItem.getData() as Task[] } export const TasksContextProvider: React.FC = ({ children }) => { - const { currentPackage } = useCurrentPackageContext() + const { station } = useCurrentStationContext() const { data, mutate, revalidate } = useSWR( - () => [currentPackage.getApiId(), "package/tasks"], - fetchTasksForPackage, + () => [station.getApiId(), "station/tasks"], + fetchTasksForStations, { suspense: true } ) diff --git a/app/models/Package.ts b/app/models/Package.ts index 38a679e..bfc7ddf 100644 --- a/app/models/Package.ts +++ b/app/models/Package.ts @@ -3,6 +3,7 @@ import { ToOneRelation } from "coloquent/dist/relation/ToOneRelation" import { AppModelWithDate } from "./AppModel" import Course from "./Course" +import Station from "./Station" import Task from "./Task" import PackageProgress from "./PackageProgress" @@ -47,6 +48,10 @@ export default class Package extends AppModelWithDate { return this.setRelation("course", course) } + setStation(station: Station) { + return this.setRelation("stations", station) + } + tasks(): ToManyRelation { return this.hasMany(Task, "tasks") } @@ -55,6 +60,10 @@ export default class Package extends AppModelWithDate { return this.getRelation("tasks") } + getStations(): Station[] { + return this.getRelation("stations") + } + packageTotalProgress(): ToOneRelation { return this.hasOne(PackageProgress, "packageTotalProgress") } diff --git a/app/models/Station.ts b/app/models/Station.ts new file mode 100644 index 0000000..78d424c --- /dev/null +++ b/app/models/Station.ts @@ -0,0 +1,73 @@ +import { ToManyRelation } from "coloquent" +import { ToOneRelation } from "coloquent/dist/relation/ToOneRelation" + +import { AppModelWithDate } from "./AppModel" +import Packages from "./Package" +import Task from "./Task" +import StationProgress from "./StationProgress" + +type Attributes = { + title: string + slug: string + sort: number +} +export default class Station extends AppModelWithDate { + protected readonly accessible: Array = [ + "title", + "slug", + "sort", + ] + protected jsonApiType: string = "stations" + + getTitle(): string { + return this.getAttribute("title") + } + + getSlug(): string { + return this.getAttribute("slug") + } + + getSort(): number { + return this.getAttribute("sort") + } + + setTitle(title: string) { + return this.setAttribute("title", title) + } + + setSlug(slug: string) { + return this.setAttribute("slug", slug) + } + + setSort(sort: number) { + return this.setAttribute("sort", sort) + } + + setPackage(packages: Packages) { + return this.setRelation("package", packages) + } + + tasks(): ToManyRelation { + return this.hasMany(Task, "tasks") + } + + getTasks(): Task[] { + return this.getRelation("tasks") + } + + stationTotalProgress(): ToOneRelation { + return this.hasOne(StationProgress, "stationTotalProgress") + } + + getStationTotalProgress(): StationProgress { + return this.getRelation("stationTotalProgress") + } + + stationUserProgress(): ToManyRelation { + return this.hasMany(StationProgress, "stationUserProgress") + } + + geStationUserProgress(): StationProgress[] { + return this.getRelation("stationUserProgress") + } +} diff --git a/app/models/StationProgress.ts b/app/models/StationProgress.ts new file mode 100644 index 0000000..4a29afe --- /dev/null +++ b/app/models/StationProgress.ts @@ -0,0 +1,24 @@ +import { ToOneRelation } from "coloquent" + +import { AppModel } from "./AppModel" +import User from "./User" + +type Attributes = {} +export default class StationProgress extends AppModel { + protected readonly accessible: Array = [] + + protected jsonApiType: string = "stationProgress" + readOnlyAttributes = ["progress"] + + public getProgress(): number | undefined { + return this.getAttribute("progress") + } + + user(): ToOneRelation { + return this.hasOne(User, "user") + } + + getUser(): User { + return this.getRelation("user") + } +} diff --git a/app/models/Task.ts b/app/models/Task.ts index f297d73..9ebf05f 100644 --- a/app/models/Task.ts +++ b/app/models/Task.ts @@ -3,6 +3,7 @@ import { ToManyRelation, ToOneRelation } from "coloquent" import { AppModelWithDate } from "./AppModel" import Package from "./Package" +import Station from "./Station" import Content from "./Content" import TaskTypeName = NSTaskAdapter.TaskTypeName @@ -104,6 +105,14 @@ export default class Task extends AppModelWithDate { return this.hasOne(Package, "package") } + public setStation(station: Station) { + return this.setRelation("station", station) + } + + public station(): ToOneRelation { + return this.hasOne(Station, "station") + } + public contents(): ToManyRelation { return this.hasMany(Content, "contents") } diff --git a/app/pages/Packages/ImportPackage.tsx b/app/pages/Packages/ImportPackage.tsx index 7aa4d3a..bfbccd5 100644 --- a/app/pages/Packages/ImportPackage.tsx +++ b/app/pages/Packages/ImportPackage.tsx @@ -36,7 +36,6 @@ const ImportPackage: React.FC = () => { formData.append("file", blob) // post formData to server - console.log("testy") try { const res = await fetch(url.href, { diff --git a/app/pages/Packages/Packages.tsx b/app/pages/Packages/Packages.tsx index e98f9b7..67abc4a 100644 --- a/app/pages/Packages/Packages.tsx +++ b/app/pages/Packages/Packages.tsx @@ -14,7 +14,7 @@ import EditPackage from "./EditPackage" import CreatePackage from "./CreatePackage" import ImportPackage from "./ImportPackage" -const Tasks = React.lazy(() => import("../Tasks/Tasks")) +const Station = React.lazy(() => import("../Stations/Stations")) const Packages: React.FC = () => { return ( @@ -36,7 +36,7 @@ const PackageSubRoute: React.FC - + ) @@ -196,7 +196,7 @@ const RenderPackageTableData: React.FC = () => { )} - + {packageItem.getTitle()} diff --git a/app/pages/Progress/Progress.tsx b/app/pages/Progress/Progress.tsx index ad2b9de..cb58a3a 100644 --- a/app/pages/Progress/Progress.tsx +++ b/app/pages/Progress/Progress.tsx @@ -37,14 +37,14 @@ const RenderProgress: React.FC = () => { - + /> */} ) diff --git a/app/pages/Progress/ProgressPerPackage.tsx b/app/pages/Progress/ProgressPerPackage.tsx index 7e090f9..6b4917b 100644 --- a/app/pages/Progress/ProgressPerPackage.tsx +++ b/app/pages/Progress/ProgressPerPackage.tsx @@ -34,10 +34,10 @@ const ProgressPerPackage: React.FC = () => { - + /> */} ) diff --git a/app/pages/Stations/CreateStation.tsx b/app/pages/Stations/CreateStation.tsx new file mode 100644 index 0000000..3a14a82 --- /dev/null +++ b/app/pages/Stations/CreateStation.tsx @@ -0,0 +1,45 @@ +import React, { useCallback } from "react" +import { RouteComponentProps, Link } from "@reach/router" + +import Station from "../../models/Station" +import { useStationContext } from "../../contexts/StationContext" +import { useCurrentPackageContext } from "../../contexts/CurrentPackageContext" + +import StationForm, { StationFormSubmitHandler } from "./StationForm" + +const CreateStation: React.FC = () => { + const { currentPackage } = useCurrentPackageContext() + const { reloadStations } = useStationContext() + + const onSubmit = useCallback( + async (values) => { + const newStation = new Station() + newStation.patch(values) + newStation.setPackage(currentPackage) + + const updated = (await newStation.save()).getModel() + if (!updated) { + throw new Error("Wasn't able to update station") + } + + await reloadStations() + }, + [currentPackage, reloadStations] + ) + + return ( + <> + + Zurück + +

Neue Station

+ + + + ) +} + +export default CreateStation diff --git a/app/pages/Stations/StationForm.tsx b/app/pages/Stations/StationForm.tsx new file mode 100644 index 0000000..6f6847d --- /dev/null +++ b/app/pages/Stations/StationForm.tsx @@ -0,0 +1,35 @@ +import React from "react" +import * as Yup from "yup" +import { SubmitHandler } from "@unform/core" + +import ValidatedForm from "../../components/Form/ValidatedForm" +import Input from "../../components/Form/Input" +import Button from "../../components/Button/Button" + +const StationFormSchema = Yup.object().shape({ + title: Yup.string().required(), + slug: Yup.string().required(), +}) +type StationFormData = Yup.InferType +export type StationFormSubmitHandler = SubmitHandler + +const StationForm: React.FC<{ + defaultValues?: Partial + onSubmit: StationFormSubmitHandler +}> = ({ defaultValues, onSubmit }) => { + return ( + + + + + + + ) +} + +export default StationForm diff --git a/app/pages/Stations/Stations.tsx b/app/pages/Stations/Stations.tsx new file mode 100644 index 0000000..bf64ee3 --- /dev/null +++ b/app/pages/Stations/Stations.tsx @@ -0,0 +1,230 @@ +import React, { Suspense, useCallback } from "react" +import { Link, RouteComponentProps, Router } from "@reach/router" + +import { CurrentStationContextProvider } from "../../contexts/CurrentStationContext" +import { + StationContextProvider, + useStationContext, +} from "../../contexts/StationContext" +import Progress from "../../components/Progress/Progress" +import Station from "../../models/Station" +import Button from "../../components/Button/Button" + +import CreateStation from "./CreateStation" + +const Tasks = React.lazy(() => import("../Tasks/Tasks")) + +const Stations: React.FC = () => { + return ( + + + + + + + + + ) +} + +const StationSubRoute: React.FC> = ({ stationId }) => { + return ( + + + + + + ) +} + +const StationsIndex: React.FC = () => { + return ( + <> + + Zurück + + + Neue Station + + + + + + + + + + + + + + + + + + } + > + + + +
Stationen
PositionNameKursfortschrittLetzte AktualisierungAktionen
+ Lade Stationen. Bitte warten. +
+ + ) +} + +const RenderStationTableData: React.FC = () => { + const { station, reloadStations, mutate } = useStationContext() + + const onRemove = useCallback( + (id?: string) => async () => { + if (!id) { + return + } + + const entity = station.find((p) => p.getApiId() === id) + + if (!entity) { + return + } + + await entity.delete() + await reloadStations() + }, + [station, reloadStations] + ) + + const moveStation = useCallback( + async (stationId: string, direction: number) => { + const stationIndex = station.findIndex( + (p) => p.getApiId() === stationId + ) + + if (stationIndex === -1) { + return + } + + if ( + (stationIndex === 0 && direction < 0) || + (stationIndex === station.length - 1 && direction > 0) + ) { + return + } + + await mutate((station) => { + let current = 0 + return station + .map((stationItem, index) => { + stationItem = stationItem.clone() + + if (index === stationIndex) { + stationItem.setSort( + stationItem.getSort() + direction + ) + } + + return stationItem + }) + .sort((a, b) => { + return a.getSort() - b.getSort() + }) + .map((p) => { + p.setSort(current++) + p.save() + + return p + }) + }, true) + }, + [station, mutate] + ) + + const stationUp = useCallback( + (stationId: string) => async () => { + await moveStation(stationId, -2) + }, + [moveStation] + ) + + const stationDown = useCallback( + (stationId: string) => async () => { + await moveStation(stationId, 2) + }, + [moveStation] + ) + + return ( + <> + {station.map((stationItem) => { + return ( + + + + {stationItem.getSort() + 1} + + {station.length > 1 && ( + <> + + + + )} + + + + {stationItem.getTitle()} + + + + + + {stationItem.getModified().toLocaleString()} + + + Bearbeiten + + + + + ) + })} + + ) +} + +export default Stations diff --git a/app/pages/Task/CreateTask.tsx b/app/pages/Task/CreateTask.tsx index b47488f..bfb1625 100644 --- a/app/pages/Task/CreateTask.tsx +++ b/app/pages/Task/CreateTask.tsx @@ -1,6 +1,7 @@ import React, { useCallback } from "react" import { RouteComponentProps, Link } from "@reach/router" +import { useCurrentStationContext } from "../../contexts/CurrentStationContext" import { useCurrentPackageContext } from "../../contexts/CurrentPackageContext" import Task from "../../models/Task" import { useTasksContext } from "../../contexts/TasksContext" @@ -8,14 +9,16 @@ import { useTasksContext } from "../../contexts/TasksContext" import TaskForm, { TaskFormSubmitHandler } from "./TaskForm" const CreateTask: React.FC = () => { + const { station } = useCurrentStationContext() const { currentPackage } = useCurrentPackageContext() + const { reloadTasks } = useTasksContext() const onSubmit = useCallback( async (values) => { const task = new Task() task.patch(values) - task.setPackage(currentPackage) + task.setStation(station) const updated = (await task.save()).getModel() if (!updated) { @@ -24,14 +27,14 @@ const CreateTask: React.FC = () => { await reloadTasks() }, - [reloadTasks, currentPackage] + [reloadTasks, station] ) return ( <> Zurück diff --git a/app/pages/Task/EditTaskContent/hooks/useGlobalContent.ts b/app/pages/Task/EditTaskContent/hooks/useGlobalContent.ts index 014e8fa..0b34af8 100644 --- a/app/pages/Task/EditTaskContent/hooks/useGlobalContent.ts +++ b/app/pages/Task/EditTaskContent/hooks/useGlobalContent.ts @@ -13,7 +13,6 @@ const useGlobalContent = () => { const { createContent, contents, setContents } = editTaskContext useEffect(() => { - console.log(contents) if (contents.length > 0) { return } diff --git a/app/pages/Tasks/Tasks.tsx b/app/pages/Tasks/Tasks.tsx index beeda64..e8a39d4 100644 --- a/app/pages/Tasks/Tasks.tsx +++ b/app/pages/Tasks/Tasks.tsx @@ -1,6 +1,7 @@ import React, { Suspense, useCallback } from "react" import { Link, RouteComponentProps, Router } from "@reach/router" +import { useCurrentStationContext } from "../../contexts/CurrentStationContext" import { useCurrentPackageContext } from "../../contexts/CurrentPackageContext" import { TasksContextProvider, @@ -12,8 +13,8 @@ import CreateTask from "../Task/CreateTask" import Task from "../../models/Task" import Button from "../../components/Button/Button" -const EditTaskContent = React.lazy( - () => import("../Task/EditTaskContent/EditTaskContent") +const EditTaskContent = React.lazy(() => + import("../Task/EditTaskContent/EditTaskContent") ) const Tasks: React.FC = () => { @@ -42,6 +43,7 @@ const TaskSubRoute: React.FC = () => { + const { station } = useCurrentStationContext() const { currentPackage } = useCurrentPackageContext() const onClick = useCallback( @@ -93,8 +95,11 @@ const TasksIndex: React.FC = () => { return ( <> -

Paket: {currentPackage.getTitle()}

- +

Station: {station.getTitle()}

+ Zurück diff --git a/migrations/3_station_table_add.php b/migrations/3_station_table_add.php new file mode 100644 index 0000000..0ba89d1 --- /dev/null +++ b/migrations/3_station_table_add.php @@ -0,0 +1,42 @@ +exec("CREATE TABLE IF NOT EXISTS `yuoshi_stations` ( + `id` varchar(32) NOT NULL, + `package_id` varchar(32) NOT NULL, + `slug` varchar(64) DEFAULT NULL, + `title` varchar(255) NOT NULL, + `mkdate` datetime NOT NULL DEFAULT current_timestamp(), + `chdate` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `slug` (`slug`) USING BTREE + ) ENGINE=InnoDB DEFAULT CHARSET=latin1"); + + $packages = \Xyng\Yuoshi\Model\Packages::getAllPackages(); + foreach ($packages as &$package) { + $station = \Xyng\Yuoshi\Model\Stations::build( + [ + 'title' => $package->title, + 'slug' => $package->slug, + 'package_id' => $package->id + ] + ); + $station->store(); + } + + $db->exec("ALTER TABLE `studip`.`yuoshi_tasks` ADD COLUMN station_id varchar(32) NULL"); + $db->exec("UPDATE `studip`.`yuoshi_tasks` SET station_id = (select id from `studip`.`yuoshi_stations` where `yuoshi_stations`.package_id = `yuoshi_tasks`.package_id limit 1)"); + $db->exec("ALTER TABLE `studip`.`yuoshi_tasks` MODIFY COLUMN `station_id` varchar(32) NOT NULL, DROP FOREIGN KEY `yuoshi_tasks_ibfk_1`, DROP COLUMN package_id, ADD FOREIGN KEY (`station_id`) REFERENCES `yuoshi_stations` (`id`)"); + } + + public function down() + { + // Nothing so far + } +} diff --git a/migrations/4_station_sequence.php b/migrations/4_station_sequence.php new file mode 100644 index 0000000..a562ff0 --- /dev/null +++ b/migrations/4_station_sequence.php @@ -0,0 +1,16 @@ +exec("ALTER TABLE `studip`.`yuoshi_stations` ADD COLUMN `sort` int NOT NULL DEFAULT 0"); + } + + public function down() + { + $db = DBManager::get(); + $db->exec("ALTER TABLE `studip`.`yuoshi_stations` DROP COLUMN `sort`"); + } +} diff --git a/package-lock.json b/package-lock.json old mode 100644 new mode 100755 diff --git a/sql/yuoshi_initial_down.sql b/sql/yuoshi_initial_down.sql index 16ba8c8..0cd9563 100644 --- a/sql/yuoshi_initial_down.sql +++ b/sql/yuoshi_initial_down.sql @@ -7,3 +7,5 @@ DROP TABLE IF EXISTS `yuoshi_task_content_quests`; DROP TABLE IF EXISTS `yuoshi_task_contents`; DROP TABLE IF EXISTS `yuoshi_tasks`; DROP TABLE IF EXISTS `yuoshi_packages`; +DROP TABLE IF EXISTS `yuoshi_stations`; + diff --git a/src/Api/Controller/PackageImportController.php b/src/Api/Controller/PackageImportController.php index 6610830..7986433 100644 --- a/src/Api/Controller/PackageImportController.php +++ b/src/Api/Controller/PackageImportController.php @@ -8,15 +8,14 @@ use Xyng\Yuoshi\Model\Packages; use Xyng\Yuoshi\Helper\PermissionHelper; -class PackageImportController extends NonJsonApiController +class PackageImportController extends NonJsonApiController { public function import(ServerRequestInterface $request, ResponseInterface $response, $args) { $course_id = $args['course_id'] ?? null; $file = $request->getUploadedFiles()["file"] ?? null; - if(!$file) - { + if (!$file) { throw new \JsonApi\Errors\BadRequestException(); } $stream = $file->getStream(); @@ -27,7 +26,7 @@ public function import(ServerRequestInterface $request, ResponseInterface $respo $package->store(); } - public function export(ServerRequestInterface $request, ResponseInterface $response, $args) + public function export(ServerRequestInterface $request, ResponseInterface $response, $args) { $package_id = $args['package_id'] ?? null; @@ -41,39 +40,45 @@ public function export(ServerRequestInterface $request, ResponseInterface $respo $export = []; $export["package"] = $package->toArrayRecursive([ - "title", - "slug", - "sort", - "tasks" => [ + "title", + "slug", + "sort", + "stations" => [ "title", + "slug", "sort", - "is_training", - "image", - "kind", - "description", - "credits", - "contents" => [ - "intro", - "outro", + "tasks" => [ "title", - "content", - "quests" => [ - "name", - "image", - "prephrase", - "question", - "multiple", - "sort", - "require_order", - "custom_answer", - "answers" => [ - "content", - "is_correct", - "sort" + "sort", + "is_training", + "image", + "kind", + "description", + "credits", + "contents" => [ + "intro", + "outro", + "title", + "content", + "quests" => [ + "name", + "image", + "prephrase", + "question", + "multiple", + "sort", + "require_order", + "custom_answer", + "answers" => [ + "content", + "is_correct", + "sort" + ] ] ] ] - ] + ], + ]); $stream = $response->getBody(); $stream->write(json_encode($export)); @@ -81,6 +86,5 @@ public function export(ServerRequestInterface $request, ResponseInterface $respo ->withBody($stream) ->withAddedHeader('Content-Disposition', 'attachment; filename="yuoshi_export.json"') ->withAddedHeader("Content-Type", "application/json"); - } -} \ No newline at end of file +} diff --git a/src/Api/Controller/PackagesController.php b/src/Api/Controller/PackagesController.php index 64c542b..55ef5c0 100644 --- a/src/Api/Controller/PackagesController.php +++ b/src/Api/Controller/PackagesController.php @@ -27,10 +27,12 @@ class PackagesController extends JsonApiController protected $allowedPagingParameters = ['offset', 'limit']; protected $allowedIncludePaths = ['packageTotalProgress', 'packageUserProgress', 'packageUserProgress.user']; - public function index(ServerRequestInterface $request, ResponseInterface $response, $args) { + public function index(ServerRequestInterface $request, ResponseInterface $response, $args) + { $course_id = $args['id'] ?? null; $filters = $this->getQueryParameters()->getFilteringParameters(); + if (!$course_id) { $course_id = $filters['course'] ?? null; } @@ -54,7 +56,8 @@ public function index(ServerRequestInterface $request, ResponseInterface $respon ); } - public function show(ServerRequestInterface $request, ResponseInterface $response, $args) { + public function show(ServerRequestInterface $request, ResponseInterface $response, $args) + { $id = $args['id'] ?? null; if (!$id) { @@ -72,7 +75,8 @@ public function show(ServerRequestInterface $request, ResponseInterface $respons return $this->getContentResponse($package); } - public function create(ServerRequestInterface $request, ResponseInterface $response, $args) { + public function create(ServerRequestInterface $request, ResponseInterface $response, $args) + { $validated = $this->validate($request, true); $data = new JsonApiDataHelper($validated); $attributes = $data->getAttributes(['title', 'slug', 'sort']); @@ -99,7 +103,8 @@ public function create(ServerRequestInterface $request, ResponseInterface $respo return $this->getContentResponse($package); } - public function update(ServerRequestInterface $request, ResponseInterface $response, $args) { + public function update(ServerRequestInterface $request, ResponseInterface $response, $args) + { $package_id = $args['id'] ?? null; if ($package_id === null) { @@ -136,7 +141,8 @@ public function update(ServerRequestInterface $request, ResponseInterface $respo return $this->getContentResponse($package); } - public function delete(ServerRequestInterface $request, ResponseInterface $response, $args) { + public function delete(ServerRequestInterface $request, ResponseInterface $response, $args) + { $package_id = $args['package_id'] ?? null; if (!$package_id) { @@ -168,5 +174,5 @@ protected function buildResourceValidationRules(Validator $validator, $new = fal } return $validator; - } + } } diff --git a/src/Api/Controller/StationController.php b/src/Api/Controller/StationController.php new file mode 100644 index 0000000..ca7cbd3 --- /dev/null +++ b/src/Api/Controller/StationController.php @@ -0,0 +1,180 @@ +getQueryParameters()->getFilteringParameters(); + if (!$package_id) { + $package_id = $filters['package'] ?? null; + } + + $conditions = []; + if ($sort = ($filters['sort'] ?? null)) { + $conditions['sort'] = $sort; + } + + $user = $this->getUser($request); + $stations = StationAuthority::findFiltered([$package_id], $user, [], [ + 'conditions' => $conditions, + 'order' => 'yuoshi_stations.sort ASC' + ]); + + + list($offset, $limit) = $this->getOffsetAndLimit(); + + return $this->getPaginatedContentResponse( + array_slice($stations, $offset, $limit), + count($stations) + ); + } + + public function show(ServerRequestInterface $request, ResponseInterface $response, $args) + { + $id = $args['id'] ?? null; + + if (!$id) { + $filters = $this->getQueryParameters()->getFilteringParameters(); + $id = $filters['id'] ?? null; + } + + $user = $this->getUser($request); + $station = StationAuthority::findOneFiltered($id, $user); + + if (!$station) { + throw new RecordNotFoundException(); + } + + return $this->getContentResponse($station); + } + + public function create(ServerRequestInterface $request, ResponseInterface $response, $args) + { + $validated = $this->validate($request, true); + $data = new JsonApiDataHelper($validated); + $attributes = $data->getAttributes(['title', 'slug', 'sort']); + $package_id = $data->getRelation('package')['data']['id'] ?? null; + + /** @var Package|null $package */ + $package = PackageAuthority::findOneFiltered($package_id, $this->getUser($request), PermissionHelper::getMasters('dozent')); + + if ($package == null) { + throw new RecordNotFoundException(); + } + + if (!PackageAuthority::canEditPackage($this->getUser($request), $package)) { + throw new AuthorizationFailedException(); + } + + $station = stations::build($attributes); + $station->sort = stations::nextSort($package->id); + $station->package_id = $package->id; + + if (!$station->store()) { + throw new InternalServerError("could not save station"); + } + + return $this->getContentResponse($station); + } + + public function update(ServerRequestInterface $request, ResponseInterface $response, $args) + { + $station_id = $args['id'] ?? null; + + if ($station_id === null) { + throw new RecordNotFoundException(); + } + + $station = StationAuthority::findOneFiltered($station_id, $this->getUser($request), PermissionHelper::getMasters('dozent')); + + if (!$station) { + throw new RecordNotFoundException(); + } + + $validated = $this->validate($request); + + $data = new JsonApiDataHelper($validated); + + if ($title = $data->getAttribute('title')) { + $station->title = $title; + } + + if ($slug = $data->getAttribute('slug')) { + $station->slug = $slug; + } + + $sort = $data->getAttribute('sort'); + if ($sort !== null) { + $station->sort = $sort; + } + + if ($station->isDirty() && !$station->store()) { + throw new InternalServerError("could not update package"); + } + + return $this->getContentResponse($station); + } + + public function delete(ServerRequestInterface $request, ResponseInterface $response, $args) + { + $station_id = $args['station_id'] ?? null; + + if (!$station_id) { + throw new RecordNotFoundException(); + } + + $station = StationAuthority::findOneFiltered($station_id, $this->getUser($request), PermissionHelper::getMasters('dozent')); + + if (!$station->delete()) { + throw new InternalServerError("could not delete entity"); + } + + return $response->withStatus(204); + } + + /** + * @inheritDoc + */ + protected function buildResourceValidationRules(Validator $validator, $new = false): Validator + { + $validator + ->rule('required', 'data.attributes.title') + ->rule('required', 'data.attributes.slug') + ->rule('numeric', 'data.attributes.sort'); + + if ($new) { + $validator + ->rule('required', 'data.relationships.package.data.id'); + } + + return $validator; + } +} diff --git a/src/Api/Controller/TasksController.php b/src/Api/Controller/TasksController.php index 682ebfa..abc9b62 100644 --- a/src/Api/Controller/TasksController.php +++ b/src/Api/Controller/TasksController.php @@ -14,6 +14,7 @@ use User; use Valitron\Validator; use Xyng\Yuoshi\Authority\PackageAuthority; +use Xyng\Yuoshi\Authority\StationAuthority; use Xyng\Yuoshi\Authority\TaskAuthority; use Xyng\Yuoshi\Authority\TaskSolutionAuthority; use Xyng\Yuoshi\Api\Exception\ValidationException; @@ -32,20 +33,21 @@ class TasksController extends JsonApiController use ValidationTrait; protected $allowedPagingParameters = ['offset', 'limit']; - protected $allowedFilteringParameters = ['sort', 'package']; + protected $allowedFilteringParameters = ['sort', 'station']; protected $allowedIncludePaths = [ 'contents', 'contents.quests', 'contents.quests.answers' ]; - public function index(ServerRequestInterface $request, ResponseInterface $response, $args) { - $package_id = $args['id'] ?? null; - $package_ids = $package_id ? [$package_id] : []; + public function index(ServerRequestInterface $request, ResponseInterface $response, $args) + { + $station_id = $args['id'] ?? null; + $station_ids = $station_id ? [$station_id] : []; $filters = $this->getQueryParameters()->getFilteringParameters(); - if (!$package_ids) { - $package_ids = explode(',', $filters['package'] ?? ''); + if (!$station_ids) { + $station_ids = explode(',', $filters['station'] ?? ''); } $sort = $filters['sort'] ?? null; @@ -54,11 +56,11 @@ public function index(ServerRequestInterface $request, ResponseInterface $respon $where['sort'] = $sort; } - if (!$package_ids) { - throw new \InvalidArgumentException("Cannot select Tasks without package filter."); + if (!$station_ids) { + throw new \InvalidArgumentException("Cannot select Tasks without station filter."); } - $tasks = TaskAuthority::findFiltered($package_ids, $this->getUser($request), [], $where); + $tasks = TaskAuthority::findFiltered($station_ids, $this->getUser($request), [], $where); list($offset, $limit) = $this->getOffsetAndLimit(); @@ -68,11 +70,12 @@ public function index(ServerRequestInterface $request, ResponseInterface $respon ); } - public function nextTask(ServerRequestInterface $request, ResponseInterface $response, $args) { + public function nextTask(ServerRequestInterface $request, ResponseInterface $response, $args) + { /** @var User $user */ $user = $this->getUser($request); - ['id' => $package_id] = $args; + ['id' => $station_id] = $args; /** @var Tasks|null $task */ $task = Tasks::findOneWithQuery([ @@ -90,7 +93,7 @@ public function nextTask(ServerRequestInterface $request, ResponseInterface $res ], 'conditions' => [ 'Solutions.id IS NULL', - 'yuoshi_tasks.package_id' => $package_id, + 'yuoshi_tasks.station_id' => $station_id, ], 'order' => [ '`yuoshi_tasks`.`sort` ASC' @@ -119,7 +122,8 @@ public function nextTask(ServerRequestInterface $request, ResponseInterface $res return $this->getContentResponse($task); } - public function show(ServerRequestInterface $request, ResponseInterface $response, $args) { + public function show(ServerRequestInterface $request, ResponseInterface $response, $args) + { $task_id = $args['id'] ?? null; if (!$task_id) { @@ -135,20 +139,21 @@ public function show(ServerRequestInterface $request, ResponseInterface $respons return $this->getContentResponse($task); } - public function create(ServerRequestInterface $request, ResponseInterface $response, $args) { + public function create(ServerRequestInterface $request, ResponseInterface $response, $args) + { $validated = $this->validate($request, true); $data = new JsonApiDataHelper($validated); + + $station_id = $data->getRelation('station')['data']['id'] ?? null; - $package_id = $data->getRelation('package')['data']['id'] ?? null; - - if (!$package_id) { + if (!$station_id) { throw new RecordNotFoundException(); } - /** @var Packages|null $package */ - $package = PackageAuthority::findOneFiltered($package_id, $this->getUser($request), PermissionHelper::getMasters('dozent')); + /** @var Station|null $station */ + $station = StationAuthority::findOneFiltered($station_id, $this->getUser($request), PermissionHelper::getMasters('dozent')); - if ($package == null) { + if ($station == null) { throw new RecordNotFoundException(); } @@ -163,7 +168,7 @@ public function create(ServerRequestInterface $request, ResponseInterface $respo [ 'sort' => 0, 'is_training' => $data->getAttribute('kind') == 'training', - 'package_id' => $package_id, + 'station_id' => $station_id, ] ); @@ -174,7 +179,8 @@ public function create(ServerRequestInterface $request, ResponseInterface $respo return $this->getContentResponse($task); } - public function update(ServerRequestInterface $request, ResponseInterface $response, $args) { + public function update(ServerRequestInterface $request, ResponseInterface $response, $args) + { $task_id = $args['id'] ?? null; if ($task_id === null) { @@ -199,13 +205,14 @@ public function update(ServerRequestInterface $request, ResponseInterface $respo // TODO: handle image if ($task->isDirty() && !$task->store()) { - throw new InternalServerError("could not update package"); + throw new InternalServerError("could not update station"); } return $this->getContentResponse($task); } - public function delete(ServerRequestInterface $request, ResponseInterface $response, $args) { + public function delete(ServerRequestInterface $request, ResponseInterface $response, $args) + { $task_id = $args['task_id'] ?? null; if (!$task_id) { @@ -228,7 +235,7 @@ protected function buildResourceValidationRules(Validator $validator, $new = fal { if ($new) { $validator - ->rule('required', 'data.relationships.package.data.id') + ->rule('required', 'data.relationships.station.data.id') ->rule('required', 'data.attributes.title') ->rule('required', 'data.attributes.kind') ->rule('required', 'data.attributes.credits'); diff --git a/src/Api/Schema/Stations.php b/src/Api/Schema/Stations.php new file mode 100644 index 0000000..8e2f9eb --- /dev/null +++ b/src/Api/Schema/Stations.php @@ -0,0 +1,85 @@ +isAdditionalField('user_id')) { + return $resource->getId($resource) . '_' . $resource->user_id; + } + + return $resource->getId($resource); + } + + /** + * @inheritDoc + */ + public function getAttributes($resource) + { + return [ + 'slug' => $resource->slug, + 'title' => $resource->title, + 'sort' => (int) $resource->sort, + 'mkdate' => $resource->mkdate->format('c'), + 'chdate' => $resource->chdate->format('c'), + ]; + } + + /** + * @param \Xyng\Yuoshi\Model\Stations $resource + * @param bool $isPrimary + * @param array $includeRelationships + * @return array + */ + public function getRelationships($resource, $isPrimary, array $includeRelationships) + { + $tasks = null; + if ($includeRelationships['tasks'] ?? null) { + $tasks = $resource->tasks; + } + + $stationTotalProgress = null; + if ($includeRelationships['stationTotalProgress']) { + /** @var User $user */ + $user = $this->getDiContainer()->get('studip-current-user'); + $stationTotalProgress = $resource->getProgress($user, false); + } + + $stationUserProgress = null; + if ($includeRelationships['stationUserProgress']) { + /** @var User $user */ + $user = $this->getDiContainer()->get('studip-current-user'); + $stationUserProgress = $resource->getProgress($user, true); + } + + return [ + 'tasks' => [ + self::DATA => $tasks, + self::SHOW_SELF => true, + self::LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($resource, 'tasks') + ], + ], + 'stationUserProgress' => [ + self::DATA => $stationUserProgress, + ], + 'stationTotalProgress' => [ + self::DATA => $stationTotalProgress, + ] + ]; + } +} diff --git a/src/Api/Schema/UserStationProgress.php b/src/Api/Schema/UserStationProgress.php new file mode 100644 index 0000000..2d39ea1 --- /dev/null +++ b/src/Api/Schema/UserStationProgress.php @@ -0,0 +1,54 @@ +isAdditionalField('user_id')) { + return []; + } + + $user = null; + if (($includeRelationships['user'] ?? null)) { + $user = $resource->user; + } + + return [ + 'user' => [ + self::DATA => $user, + ], + ]; + } + + /** + * @inheritDoc + */ + public function getId($resource) + { + if ($resource->isAdditionalField('user_id')) { + return $resource->getId() . '_' . $resource->user_id; + } + return $resource->getId(); + } + + /** + * @inheritDoc + */ + public function getAttributes($resource) + { + return [ + 'progress' => $resource->isAdditionalField('progress') ? (float) $resource->progress : null, + ]; + } +} diff --git a/src/Authority/PackageAuthority.php b/src/Authority/PackageAuthority.php index 8553168..1f2c421 100644 --- a/src/Authority/PackageAuthority.php +++ b/src/Authority/PackageAuthority.php @@ -7,20 +7,24 @@ use Xyng\Yuoshi\Helper\PermissionHelper; use Xyng\Yuoshi\Model\Packages; -class PackageAuthority implements AuthorityInterface { - public static function canEditPackage(User $user, Packages $package) { +class PackageAuthority implements AuthorityInterface +{ + public static function canEditPackage(User $user, Packages $package) + { return $GLOBALS['perm']->have_studip_perm('dozent', $package->course_id, $user->id); } - public static function canSeePackage(User $user, Packages $package) { + public static function canSeePackage(User $user, Packages $package) + { return $GLOBALS['perm']->have_studip_perm('user', $package->course_id, $user->id); } - public static function filterByUsersCourses() { + public static function filterByUsersCourses() + { return "INNER JOIN seminar_user on (seminar_user.Seminar_id = yuoshi_packages.course_id and seminar_user.user_id = :user_id)"; } - static function getFilter(): string + public static function getFilter(): string { return static::filterByUsersCourses(); } @@ -28,7 +32,7 @@ static function getFilter(): string /** * @inheritDoc */ - static function findFiltered(array $ids, User $user, array $perms = [], array $conditions = []): array + public static function findFiltered(array $ids, User $user, array $perms = [], array $conditions = []): array { return Packages::findWithQuery( AuthorityHelper::getFilterQuery(static::getFilter(), 'seminar_user.Seminar_id', $ids, $user, $perms, $conditions) @@ -38,7 +42,7 @@ static function findFiltered(array $ids, User $user, array $perms = [], array $c /** * @inheritDoc */ - static function findOneFiltered(string $id, User $user, array $perms = [], array $conditions = []): ?SimpleORMap + public static function findOneFiltered(string $id, User $user, array $perms = [], array $conditions = []): ?SimpleORMap { return Packages::findOneWithQuery( AuthorityHelper::getFilterQuery(static::getFilter(), 'yuoshi_packages.id', $id, $user, $perms, $conditions) diff --git a/src/Authority/StationAuthority.php b/src/Authority/StationAuthority.php new file mode 100644 index 0000000..26a38fc --- /dev/null +++ b/src/Authority/StationAuthority.php @@ -0,0 +1,52 @@ +have_studip_perm('dozent', $station->course_id, $user->id); + } + + public static function canSeeStation(User $user, Station $station) + { + return $GLOBALS['perm']->have_studip_perm('user', $station->station_id, $user->id); + } + + public static function filterByUsersPackages() + { + $packageJoin = PackageAuthority::filterByUsersCourses(); + return "INNER JOIN yuoshi_packages on (yuoshi_packages.id = yuoshi_stations.package_id) " . $packageJoin; + } + + public static function getFilter(): string + { + return static::filterByUsersPackages(); + } + + /** + * @inheritDoc + */ + public static function findFiltered(array $ids, User $user, array $perms = [], array $conditions = []): array + { + return Stations::findWithQuery( + AuthorityHelper::getFilterQuery(static::getFilter(), 'yuoshi_stations.package_id', $ids, $user, $perms, $conditions) + ); + } + + /** + * @inheritDoc + */ + public static function findOneFiltered(string $id, User $user, array $perms = [], array $conditions = []): ?SimpleORMap + { + return Stations::findOneWithQuery( + AuthorityHelper::getFilterQuery(static::getFilter(), 'yuoshi_stations.id', $id, $user, $perms, $conditions) + ); + } +} diff --git a/src/Authority/TaskAuthority.php b/src/Authority/TaskAuthority.php index d0d7fea..94788f3 100644 --- a/src/Authority/TaskAuthority.php +++ b/src/Authority/TaskAuthority.php @@ -6,13 +6,16 @@ use Xyng\Yuoshi\Helper\AuthorityHelper; use Xyng\Yuoshi\Model\Tasks; -class TaskAuthority implements AuthorityInterface { - public static function filterByUsersPackages(): string { - $packageJoin = PackageAuthority::filterByUsersCourses(); - return "INNER JOIN yuoshi_packages on (yuoshi_packages.id = yuoshi_tasks.package_id) " . $packageJoin; +class TaskAuthority implements AuthorityInterface +{ + public static function filterByUsersPackages(): string + { + $stationJoin = StationAuthority::filterByUsersPackages(); + + return "INNER JOIN yuoshi_stations on (yuoshi_stations.id = yuoshi_tasks.station_id) " . $stationJoin; } - static function getFilter(): string + public static function getFilter(): string { return static::filterByUsersPackages(); } @@ -20,17 +23,17 @@ static function getFilter(): string /** * @inheritDoc */ - static function findFiltered(array $ids, User $user, array $perms = [], array $conditions = []): array + public static function findFiltered(array $ids, User $user, array $perms = [], array $conditions = []): array { return Tasks::findWithQuery( - AuthorityHelper::getFilterQuery(static::getFilter(), 'yuoshi_packages.id', $ids, $user, $perms, $conditions) + AuthorityHelper::getFilterQuery(static::getFilter(), 'yuoshi_stations.id', $ids, $user, $perms, $conditions) ); } /** * @inheritDoc */ - static function findOneFiltered(string $id, User $user, array $perms = [], array $conditions = []): ?SimpleORMap + public static function findOneFiltered(string $id, User $user, array $perms = [], array $conditions = []): ?SimpleORMap { return Tasks::findOneWithQuery( AuthorityHelper::getFilterQuery(static::getFilter(), 'yuoshi_tasks.id', $id, $user, $perms, $conditions) diff --git a/src/Model/Packages.php b/src/Model/Packages.php index 965295f..c530db2 100644 --- a/src/Model/Packages.php +++ b/src/Model/Packages.php @@ -21,59 +21,52 @@ * * @method static Packages find(string $id) */ -class Packages extends BaseModel { - protected static function configure($config = []) { +class Packages extends BaseModel +{ + protected static function configure($config = []) + { $config['db_table'] = 'yuoshi_packages'; - - $config['has_many']['tasks'] = [ - 'class_name' => Tasks::class, + $config['has_many']['stations'] = [ + 'class_name' => Stations::class, 'assoc_func' => 'findByPackage_id', 'assoc_foreign_key' => 'package_id', 'on_delete' => true, 'on_store' => true, ]; - $config['belongs_to']['course'] = [ 'class_name' => Course::class, 'foreign_key' => 'course_id' ]; - parent::configure($config); } - // TODO: check in db if this package is playable by user. public $playable = true; - - public static function nextSort(string $course_id) { + public static function nextSort(string $course_id) + { $db_table = static::config('db_table'); $maxSortStmt = \DBManager::get()->prepare("SELECT max(`sort`) as max_sort FROM `$db_table` WHERE `course_id` = :courseId GROUP BY `coursE_id`"); $maxSortStmt->execute([ 'courseId' => $course_id, ]); - $maxSort = $maxSortStmt->fetch(); if ($maxSort === false) { return 0; } - return ((int) $maxSort['max_sort']) + 1; } - /** * @param User $user * @param bool $byUsers * @return UserPackageProgress|null|UserPackageProgress[] */ - public function getProgress(User $user, bool $byUsers = false) { + public function getProgress(User $user, bool $byUsers = false) + { $solvedTaskCount = 'count(distinct concat(`yuoshi_user_task_solutions`.`task_id`, `yuoshi_user_task_solutions`.`user_id`))'; - $studentJoinConditions = []; - $isDozent = PermissionHelper::getPerm()->have_studip_perm('dozent', $this->course_id, $user->id); if (!$isDozent) { $studentJoinConditions['Students.user_id'] = $user->id; } - $query = [ 'joins' => [ [ @@ -82,6 +75,13 @@ public function getProgress(User $user, bool $byUsers = false) { 'user_id' => $user->id, ] ], + [ + 'type' => 'left', + 'table' => 'yuoshi_stations', + 'on' => [ + 'yuoshi_stations.package_id' => new QueryField('yuoshi_packages.id'), + ], + ], [ // we have two joins on the tasks table. // this join is not used by the other joins so we can get @@ -90,7 +90,7 @@ public function getProgress(User $user, bool $byUsers = false) { 'table' => 'yuoshi_tasks', 'alias' => 'TotalTasks', 'on' => [ - 'yuoshi_packages.id' => new QueryField('TotalTasks.package_id') + 'yuoshi_stations.id' => new QueryField('TotalTasks.station_id') ] ], [ @@ -98,7 +98,7 @@ public function getProgress(User $user, bool $byUsers = false) { 'type' => 'left', 'table' => 'yuoshi_tasks', 'on' => [ - 'yuoshi_packages.id' => new QueryField('yuoshi_tasks.package_id') + 'yuoshi_tasks.station_id' => new QueryField('yuoshi_tasks.station_id') ] ], [ @@ -132,9 +132,7 @@ public function getProgress(User $user, bool $byUsers = false) { 'yuoshi_packages.id' ] ]; - $progressCount = '(' . $solvedTaskCount . '* 100) / (count(distinct `TotalTasks`.`id`) * count(distinct `yuoshi_user_task_solutions`.`user_id`))'; - if (!$byUsers) { return UserPackageProgress::findOneWithQuery( $query, @@ -143,7 +141,6 @@ public function getProgress(User $user, bool $byUsers = false) { ] ); } - return UserPackageProgress::findWithQuery( $query, [ @@ -152,4 +149,9 @@ public function getProgress(User $user, bool $byUsers = false) { ] ); } + + public function getAllPackages() + { + return Packages::findBySQL('1'); + } } diff --git a/src/Model/Stations.php b/src/Model/Stations.php new file mode 100644 index 0000000..4f38872 --- /dev/null +++ b/src/Model/Stations.php @@ -0,0 +1,161 @@ + Tasks::class, + 'assoc_func' => 'findByStation_id', + 'assoc_foreign_key' => 'station_id', + 'on_delete' => true, + 'on_store' => true, + ]; + + $config['belongs_to']['package'] = [ + 'class_name' => Packages::class, + 'foreign_key' => 'package_id' + ]; + + parent::configure($config); + } + + public static function nextSort(string $package_id) + { + $db_table = static::config('db_table'); + $maxSortStmt = \DBManager::get()->prepare("SELECT max(`sort`) as max_sort FROM `$db_table` WHERE `package_id` = :packageId GROUP BY `package_id`"); + $maxSortStmt->execute([ + 'packageId' => $package_id, + ]); + + $maxSort = $maxSortStmt->fetch(); + if ($maxSort === false) { + return 0; + } + + return ((int) $maxSort['max_sort']) + 1; + } + + /** + * @param User $user + * @param bool $byUsers + * @return UserStationProgress|null|UserStationProgress[] + */ + public function getProgress(User $user, bool $byUsers = false) + { + $solvedTaskCount = 'count(distinct concat(`yuoshi_user_task_solutions`.`task_id`, `yuoshi_user_task_solutions`.`user_id`))'; + + $studentJoinConditions = []; + + $isDozent = PermissionHelper::getPerm()->have_studip_perm('dozent', $this->package->course_id, $user->id); + if (!$isDozent) { + $studentJoinConditions['Students.user_id'] = $user->id; + } + + $query = [ + 'joins' => [ + [ + 'sql' => StationAuthority::getFilter(), + 'params' => [ + 'user_id' => $user->id, + ] + ], + [ + // we have two joins on the tasks table. + // this join is not used by the other joins so we can get + // the total task count + 'type' => 'left', + 'table' => 'yuoshi_tasks', + 'alias' => 'TotalTasks', + 'on' => [ + 'yuoshi_stations.id' => new QueryField('TotalTasks.station_id') + ] + ], + [ + // this one is just an intermediate join so we can get the solutions + 'type' => 'left', + 'table' => 'yuoshi_tasks', + 'on' => [ + 'yuoshi_stations.id' => new QueryField('yuoshi_tasks.station_id') + ] + ], + [ + 'type' => 'left', + 'table' => 'yuoshi_user_task_solutions', + 'on' => [ + 'yuoshi_tasks.id' => new QueryField('yuoshi_user_task_solutions.task_id'), + 'yuoshi_user_task_solutions.finished is not null', + ] + ], + [ + 'type' => 'left', + 'table' => 'seminar_user', + 'alias' => 'Students', + 'on' => [ + 'Students.Seminar_id' => new QueryField('yuoshi_packages.course_id'), + 'Students.status IN' => PermissionHelper::getSlaves('tutor'), + 'Students.user_id' => new QueryField('yuoshi_user_task_solutions.user_id'), + ] + $studentJoinConditions, + ], + ], + 'conditions' => [ + 'yuoshi_stations.id' => $this->id, + ] + ($byUsers ? [ + 'Students.user_id is not null' + ] : []), + 'group' => $byUsers ? [ + 'yuoshi_stations.id', + 'yuoshi_user_task_solutions.user_id', + ] : [ + 'yuoshi_stations.id' + ] + ]; + + $progressCount = '(' . $solvedTaskCount . '* 100) / (count(distinct `TotalTasks`.`id`) * count(distinct `yuoshi_user_task_solutions`.`user_id`))'; + + if (!$byUsers) { + /** @var UserStationProgress|null $result */ + $result = UserStationProgress::findOneWithQuery( + $query, + [ + 'progress' => $progressCount, + ] + ); + + return $result; + } + + /** @var UserStationProgress[] $result */ + $result = UserStationProgress::findWithQuery( + $query, + [ + 'progress' => $progressCount, + 'user_id' => 'yuoshi_user_task_solutions.user_id' + ] + ); + + return $result; + } +} diff --git a/src/Model/Tasks.php b/src/Model/Tasks.php index 8b7bd7b..5f6faef 100644 --- a/src/Model/Tasks.php +++ b/src/Model/Tasks.php @@ -2,6 +2,7 @@ namespace Xyng\Yuoshi\Model; use SimpleORMap; +use Xyng\Yuoshi\Model\Stations; /** * Class Tasks @@ -10,17 +11,19 @@ * @property string $title * @property string $kind * @property number $credits - * @property Packages $package + * @property Stations $station * @property \SimpleORMapCollection|TaskContents[] $contents */ -class Tasks extends BaseModel { +class Tasks extends BaseModel +{ public static $types = ['card', 'cloze', 'drag', 'memory', 'multi', 'survey', 'tag', 'training']; - protected static function configure($config = []) { + protected static function configure($config = []) + { $config['db_table'] = 'yuoshi_tasks'; - $config['belongs_to']['package'] = [ - 'class_name' => Packages::class, - 'foreign_key' => 'package_id' + $config['belongs_to']['station'] = [ + 'class_name' => Stations::class, + 'foreign_key' => 'station_id' ]; $config['has_many']['contents'] = [ diff --git a/src/Model/UserStationProgress.php b/src/Model/UserStationProgress.php new file mode 100644 index 0000000..67fedba --- /dev/null +++ b/src/Model/UserStationProgress.php @@ -0,0 +1,21 @@ + User::class, + 'foreign_key' => 'user_id' + ]; + + parent::configure($config); + } +}