Reimplemented shifts ical export

This commit is contained in:
Igor Scheller 2023-01-29 00:46:51 +01:00 committed by Michael Weimann
parent 3d0d5067fd
commit b0b4cb54ec
7 changed files with 109 additions and 89 deletions

View File

@ -98,6 +98,7 @@ $route->get('/api[/{resource:.+}]', 'ApiController@index');
// Feeds // Feeds
$route->get('/atom', 'FeedController@atom'); $route->get('/atom', 'FeedController@atom');
$route->get('/ical', 'FeedController@ical');
$route->get('/rss', 'FeedController@rss'); $route->get('/rss', 'FeedController@rss');
// Design // Design

View File

@ -9,7 +9,6 @@ use Engelsystem\Models\Shifts\Shift;
use Engelsystem\Models\Shifts\ShiftType; use Engelsystem\Models\Shifts\ShiftType;
use Engelsystem\Models\Shifts\ShiftSignupStatus; use Engelsystem\Models\Shifts\ShiftSignupStatus;
use Engelsystem\ShiftSignupState; use Engelsystem\ShiftSignupState;
use Illuminate\Support\Collection;
/** /**
* @param array|Shift $shift * @param array|Shift $shift
@ -382,7 +381,7 @@ function shift_next_controller()
/** /**
* Export filtered shifts via JSON. * Export filtered shifts via JSON.
* (Like iCal Export or shifts view) * (Like shifts view)
*/ */
function shifts_json_export_controller() function shifts_json_export_controller()
{ {
@ -396,7 +395,7 @@ function shifts_json_export_controller()
throw new HttpForbidden('{"error":"Not allowed"}', ['content-type' => 'application/json']); throw new HttpForbidden('{"error":"Not allowed"}', ['content-type' => 'application/json']);
} }
$shifts = load_ical_shifts(); $shifts = Shifts_by_user(auth()->user()->id);
$shifts->sortBy('start_date'); $shifts->sortBy('start_date');
$timeZone = CarbonTimeZone::create(config('timezone')); $timeZone = CarbonTimeZone::create(config('timezone'));
@ -460,13 +459,3 @@ function shifts_json_export_controller()
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
raw_output(json_encode($shiftsData)); raw_output(json_encode($shiftsData));
} }
/**
* Returns users shifts to export.
*
* @return Shift[]|Collection
*/
function load_ical_shifts()
{
return Shifts_by_user(auth()->user()->id);
}

View File

@ -1,67 +0,0 @@
<?php
use Engelsystem\Http\Exceptions\HttpForbidden;
use Engelsystem\Models\Shifts\Shift;
use Illuminate\Support\Collection;
/**
* Controller for ical output of users own shifts or any user_shifts filter.
*/
function user_ical()
{
$user = auth()->userFromApi();
if (!$user) {
throw new HttpForbidden('Missing or invalid ?key=', ['content-type' => 'text/text']);
}
if (!auth()->can('ical')) {
throw new HttpForbidden('Not allowed', ['content-type' => 'text/text']);
}
$ical_shifts = load_ical_shifts();
send_ical_from_shifts($ical_shifts);
}
/**
* Renders an ical calendar from given shifts array.
*
* @param Shift[]|Collection $shifts Shift
*/
function send_ical_from_shifts($shifts)
{
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: attachment; filename=shifts.ics');
$output = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//-//" . config('app_name') . "//DE\r\nCALSCALE:GREGORIAN\r\n";
foreach ($shifts as $shift) {
$output .= make_ical_entry_from_shift($shift);
}
$output .= "END:VCALENDAR\r\n";
$output = trim($output, "\x0A");
header('Content-Length: ' . strlen($output));
raw_output($output);
}
/**
* Renders an ical vevent from given shift.
*
* @param Shift $shift
* @return string
*/
function make_ical_entry_from_shift(Shift $shift)
{
$output = "BEGIN:VEVENT\r\n";
$output .= 'UID:' . md5($shift->start->timestamp . $shift->end->timestamp . $shift->shiftType->name) . "\r\n";
$output .= 'SUMMARY:' . str_replace("\n", "\\n", $shift->shiftType->name)
. ' (' . str_replace("\n", "\\n", $shift->title) . ")\r\n";
if (isset($shift->user_comment)) {
$output .= 'DESCRIPTION:' . str_replace("\n", "\\n", $shift->user_comment) . "\r\n";
}
$output .= 'DTSTAMP:' . $shift->start->utc()->format('Ymd\THis\Z') . "\r\n";
$output .= 'DTSTART:' . $shift->start->utc()->format('Ymd\THis\Z') . "\r\n";
$output .= 'DTEND:' . $shift->end->utc()->format('Ymd\THis\Z') . "\r\n";
$output .= 'LOCATION:' . $shift->room->name . "\r\n";
$output .= "END:VEVENT\r\n";
return $output;
}

View File

@ -0,0 +1,26 @@
{% set dateFormat = 'Ymd\\THis\\Z' %}
{% set replacement = {'\n': '\\n'} %}
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//-/{{ config('app_name') }}//DE
CALSCALE:GREGORIAN
{% for entry in shiftEntries %}
BEGIN:VEVENT
UID:{{ uuidBy(entry.id, '54117') }}
DTSTAMP:{{ entry.shift.start.utc().format(dateFormat) }}
DTSTART:{{ entry.shift.start.utc().format(dateFormat) }}
DTEND:{{ entry.shift.end.utc().format(dateFormat) }}
STATUS:CONFIRMED
TRANSP:OPAQUE
SUMMARY:{{ entry.shift.shiftType.name ~ ' (' ~ entry.shift.title ~ ')' | replace(replacement) | raw }}
LOCATION:{{ entry.shift.room.name | replace(replacement) | raw }}
DESCRIPTION:{{
entry.shift.shiftType.description
~ '\\n' ~ entry.shift.description
~ '\\n' ~ entry.user_comment
| replace(replacement) | raw
}}
URL:{{ url('/shifts', {'action': 'view', 'shift_id': entry.shift.id}) | raw }}
END:VEVENT
{% endfor %}
END:VCALENDAR

View File

@ -2,6 +2,7 @@
namespace Engelsystem\Controllers; namespace Engelsystem\Controllers;
use Engelsystem\Helpers\Authenticator;
use Engelsystem\Http\Request; use Engelsystem\Http\Request;
use Engelsystem\Http\Response; use Engelsystem\Http\Response;
use Engelsystem\Models\News; use Engelsystem\Models\News;
@ -13,9 +14,11 @@ class FeedController extends BaseController
protected array $permissions = [ protected array $permissions = [
'atom' => 'atom', 'atom' => 'atom',
'rss' => 'atom', 'rss' => 'atom',
'ical' => 'ical',
]; ];
public function __construct( public function __construct(
protected Authenticator $auth,
protected Request $request, protected Request $request,
protected Response $response, protected Response $response,
) { ) {
@ -39,6 +42,16 @@ class FeedController extends BaseController
->withView('api/rss', ['news' => $news]); ->withView('api/rss', ['news' => $news]);
} }
public function ical(): Response
{
$shifts = $this->getShifts();
return $this->response
->withHeader('content-type', 'text/calendar; charset=utf-8')
->withHeader('content-disposition', 'attachment; filename=shifts.ics')
->withView('api/ical', ['shiftEntries' => $shifts]);
}
protected function getNews(): Collection protected function getNews(): Collection
{ {
$news = $this->request->has('meetings') $news = $this->request->has('meetings')
@ -50,4 +63,14 @@ class FeedController extends BaseController
return $news->get(); return $news->get();
} }
protected function getShifts(): Collection
{
return $this->auth->userFromApi()
->shiftEntries()
->leftJoin('shifts', 'shifts.id', 'shift_entries.shift_id')
->orderBy('shifts.start')
->with(['shift', 'shift.room', 'shift.shiftType'])
->get();
}
} }

View File

@ -17,7 +17,6 @@ class LegacyMiddleware implements MiddlewareInterface
protected array $free_pages = [ protected array $free_pages = [
'admin_event_config', 'admin_event_config',
'angeltypes', 'angeltypes',
'ical',
'public_dashboard', 'public_dashboard',
'rooms', 'rooms',
'shift_entries', 'shift_entries',
@ -78,10 +77,6 @@ class LegacyMiddleware implements MiddlewareInterface
protected function loadPage(string $page): array protected function loadPage(string $page): array
{ {
switch ($page) { switch ($page) {
case 'ical':
require_once realpath(__DIR__ . '/../../includes/pages/user_ical.php');
user_ical();
break;
case 'shifts_json_export': case 'shifts_json_export':
require_once realpath(__DIR__ . '/../../includes/controller/shifts_controller.php'); require_once realpath(__DIR__ . '/../../includes/controller/shifts_controller.php');
shifts_json_export_controller(); shifts_json_export_controller();

View File

@ -3,19 +3,27 @@
namespace Engelsystem\Test\Unit\Controllers; namespace Engelsystem\Test\Unit\Controllers;
use Engelsystem\Controllers\FeedController; use Engelsystem\Controllers\FeedController;
use Engelsystem\Helpers\Authenticator;
use Engelsystem\Helpers\Carbon; use Engelsystem\Helpers\Carbon;
use Engelsystem\Models\News; use Engelsystem\Models\News;
use Engelsystem\Models\Shifts\ShiftEntry;
use Engelsystem\Models\User\User;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
class FeedControllerTest extends ControllerTest class FeedControllerTest extends ControllerTest
{ {
protected Authenticator|MockObject $auth;
/** /**
* @covers \Engelsystem\Controllers\FeedController::__construct * @covers \Engelsystem\Controllers\FeedController::__construct
* @covers \Engelsystem\Controllers\FeedController::atom * @covers \Engelsystem\Controllers\FeedController::atom
*/ */
public function testAtom(): void public function testAtom(): void
{ {
$controller = new FeedController($this->request, $this->response); $controller = new FeedController($this->auth, $this->request, $this->response);
$this->setExpects( $this->setExpects(
$this->response, $this->response,
@ -40,7 +48,7 @@ class FeedControllerTest extends ControllerTest
*/ */
public function testRss(): void public function testRss(): void
{ {
$controller = new FeedController($this->request, $this->response); $controller = new FeedController($this->auth, $this->request, $this->response);
$this->setExpects( $this->setExpects(
$this->response, $this->response,
@ -59,6 +67,50 @@ class FeedControllerTest extends ControllerTest
$controller->rss(); $controller->rss();
} }
/**
* @covers \Engelsystem\Controllers\FeedController::ical
* @covers \Engelsystem\Controllers\FeedController::getShifts
*/
public function testIcal(): void
{
$this->request = $this->request->withQueryParams(['key' => 'fo0']);
$this->auth = new Authenticator(
$this->request,
new Session(new MockArraySessionStorage()),
new User(),
);
$controller = new FeedController($this->auth, $this->request, $this->response);
/** @var User $user */
$user = User::factory()->create(['api_key' => 'fo0']);
ShiftEntry::factory(3)->create(['user_id' => $user->id]);
$this->response->expects($this->exactly(2))
->method('withHeader')
->withConsecutive(
['content-type', 'text/calendar; charset=utf-8'],
['content-disposition', 'attachment; filename=shifts.ics']
)
->willReturn($this->response);
$this->response->expects($this->once())
->method('withView')
->willReturnCallback(function ($view, $data) {
$this->assertEquals('api/ical', $view);
$this->assertArrayHasKey('shiftEntries', $data);
/** @var ShiftEntry[]|Collection $shiftEntries */
$shiftEntries = $data['shiftEntries'];
$this->assertCount(3, $shiftEntries);
$this->assertTrue($shiftEntries[0]->shift->start < $shiftEntries[1]->shift->start);
return $this->response;
});
$controller->ical();
}
public function getNewsMeetingsDataProvider(): array public function getNewsMeetingsDataProvider(): array
{ {
return [ return [
@ -73,7 +125,7 @@ class FeedControllerTest extends ControllerTest
*/ */
public function testGetNewsMeetings(bool $isMeeting): void public function testGetNewsMeetings(bool $isMeeting): void
{ {
$controller = new FeedController($this->request, $this->response); $controller = new FeedController($this->auth, $this->request, $this->response);
$this->request->attributes->set('meetings', $isMeeting); $this->request->attributes->set('meetings', $isMeeting);
$this->setExpects($this->response, 'withHeader', null, $this->response); $this->setExpects($this->response, 'withHeader', null, $this->response);
@ -100,7 +152,7 @@ class FeedControllerTest extends ControllerTest
public function testGetNewsLimit(): void public function testGetNewsLimit(): void
{ {
News::query()->where('id', '<>', 1)->update(['updated_at' => Carbon::now()->subHour()]); News::query()->where('id', '<>', 1)->update(['updated_at' => Carbon::now()->subHour()]);
$controller = new FeedController($this->request, $this->response); $controller = new FeedController($this->auth, $this->request, $this->response);
$this->setExpects($this->response, 'withHeader', null, $this->response); $this->setExpects($this->response, 'withHeader', null, $this->response);
$this->response->expects($this->once()) $this->response->expects($this->once())
@ -125,6 +177,7 @@ class FeedControllerTest extends ControllerTest
parent::setUp(); parent::setUp();
$this->config->set('display_news', 10); $this->config->set('display_news', 10);
$this->auth = $this->createMock(Authenticator::class);
News::factory(7)->create(['is_meeting' => false]); News::factory(7)->create(['is_meeting' => false]);
News::factory(5)->create(['is_meeting' => true]); News::factory(5)->create(['is_meeting' => true]);