Merge branch 'develop' into trunk

This commit is contained in:
Vincent BRIDIER 2021-06-23 19:48:48 +02:00
commit 14c3f4b968
112 changed files with 3707 additions and 494 deletions

View file

@ -32,6 +32,7 @@ MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=null
MAIL_FROM_NAME="${APP_NAME}"
MAIL_LIST_SERVICEWEB=informatique.internet@ldc.fr
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=

4
.gitignore vendored
View file

@ -6,6 +6,8 @@
/vendor
.env
.env.backup
.env.dev
.env.prod
.phpunit.result.cache
Homestead.json
Homestead.yaml
@ -15,5 +17,3 @@ package-lock.json
composer.lock
.idea/
.vscode/
/public/css
/public/js

6
IDEAS.md Normal file
View file

@ -0,0 +1,6 @@
- Permettre à un user de suivre un projet
- ~~permettre d'insérer des @user dans le texte avec lien vers user~~
~~pas de liens vers user mais mise en form~~
- ~~parse de @user + envoie de mail si un seul user est trouvé~~
- permettre de modifier son mot de passe ? (je crois que ça n'as pas été mis exprès)
- ~~parser les liens http~~

View file

@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Enums\Role;
use App\Models\Project;
use App\Models\User;
use Illuminate\Console\Command;
@ -41,34 +42,27 @@ class CreateUser extends Command
*/
public function handle()
{
$admin = false;
$name = $this->ask('What is the name?');
$email = $this->ask('What is the email?');
$pwd = $this->secret('What is the password?');
$user = new User();
$user->name = $name;
$user->email = $email;
if ($this->confirm('Is the user an admin?')) {
$admin = true;
$user->role = Role::admin();
} else {
$project = null;
$user->role = Role::client();
try {
$project = Project::where(
'id',
$this->ask("What's the id of the project (s)he's working on?")
)->firstOrFail();
$user->projects()->sync([$project->id]);
} catch (ModelNotFoundException $e) {
$this->error('The project does not exists');
return -1;
}
}
$user = new User();
$user->name = $name;
$user->email = $email;
if ($admin) {
$user->role = 1;
} else {
$user->project_id = $project->id;
}
$user->password = Hash::make($pwd);
$user->save();

49
app/Enums/CrudAction.php Normal file
View file

@ -0,0 +1,49 @@
<?php
namespace App\Enums;
use Kwaadpepper\Enum\BaseEnum;
/**
*
* https://laravel.com/docs/8.x/authorization
* Controller Method Policy Method
* index viewAny
* show view
* create create
* store create
* edit update
* update update
* destroy delete
*
* @method static self viewAny()
* @method static self view()
* @method static self create()
* @method static self update()
* @method static self delete()
*/
class CrudAction extends BaseEnum
{
protected static function values(): array
{
return [
'viewAny' => 0,
'view' => 1,
'create' => 2,
'update' => 3,
'delete' => 4,
];
}
protected static function labels(): array
{
return [
'viewAny' => trans('Liste'),
'view' => trans('Voir'),
'create' => trans('Créer'),
'update' => trans('Éditer'),
'delete' => trans('Supprimer'),
];
}
}

56
app/Enums/CrudType.php Normal file
View file

@ -0,0 +1,56 @@
<?php
namespace App\Enums;
use Kwaadpepper\Enum\BaseEnum;
/**
* @method static self boolean()
* @method static self int()
* @method static self string()
* @method static self text()
* @method static self json()
* @method static self email()
* @method static self password()
* @method static self float()
* @method static self enum()
* @method static self belongsTo()
* @method static self belongsToMany()
*/
class CrudType extends BaseEnum
{
protected static function values(): array
{
return [
'boolean' => 0,
'int' => 1,
'string' => 2,
'text' => 3,
'json' => 4,
'email' => 5,
'password' => 6,
'float' => 7,
'enum' => 8,
'belongsTo' => 9,
'belongsToMany' => 10
];
}
protected static function labels(): array
{
return [
'boolean' => trans('Booléen'),
'int' => trans('Nombre'),
'string' => trans('Chaîne de charactères simple'),
'text' => trans('Texte'),
'json' => trans('Json'),
'email' => trans('Chaîne de charactères email'),
'password' => trans('Mot de passe'),
'float' => trans('Nombre à virgule flotante'),
'enum' => trans('Énumération'),
'belongsTo' => trans('Clé étrangère'),
'belongsToMany' => trans('Relation Belongs To Many')
];
}
}

29
app/Enums/Role.php Normal file
View file

@ -0,0 +1,29 @@
<?php
namespace App\Enums;
use Kwaadpepper\Enum\BaseEnum;
/**
* @method static self admin()
* @method static self client()
*/
class Role extends BaseEnum
{
protected static function values(): array
{
return [
'admin' => 1,
'client' => 2
];
}
protected static function labels(): array
{
return [
'admin' => trans('Administrateur'),
'client' => trans('Client')
];
}
}

View file

@ -6,10 +6,16 @@ use Kwaadpepper\Enum\BaseEnum;
/**
* @method static self new()
* @method static self estimateTodo()
* @method static self estimateValidated()
* @method static self toPlan()
* @method static self planned()
* @method static self inprogress()
* @method static self waiting()
* @method static self processed()
* @method static self archived()
* @method static self recette()
* @method static self production()
* @method static self archived()
*/
class TicketStatus extends BaseEnum
{
@ -17,10 +23,16 @@ class TicketStatus extends BaseEnum
{
return [
'new' => 1,
'inprogress' => 2,
'processed' => 3,
'archived' => 4,
'production' => 5
'estimateTodo' => 2,
'estimateValidated' => 3,
'toPlan' => 4,
'planned' => 5,
'inprogress' => 7,
'waiting' => 9,
'processed' => 10,
'recette' => 12,
'production' => 13,
'archived' => 15,
];
}
@ -28,10 +40,16 @@ class TicketStatus extends BaseEnum
{
return [
'new' => trans('Nouveau'),
'estimateTodo' => trans('Devis à faire'),
'estimateValidated' => trans('Devis validé'),
'toPlan' => trans('À planifier'),
'planned' => trans('Planifié'),
'inprogress' => trans('En cours'),
'waiting' => trans('En attente'),
'processed' => trans('Traité'),
'archived' => trans('Archivé'),
'recette' => trans('En recettage'),
'production' => trans('En production'),
'archived' => trans('Archivé'),
];
}
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class CrudException extends Exception
{
}

View file

@ -3,9 +3,13 @@
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Support\Facades\Mail;
use RuntimeException;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* A list of the exception types that are not reported.
*
@ -25,6 +29,45 @@ class Handler extends ExceptionHandler
'password_confirmation',
];
/**
* Report or log an exception.
*
* @param \Throwable $exception
* @return void
*
* @throws \Exception
*/
public function report(Throwable $e)
{
if (get_class($e) == RedirectTo::class) {
abort(redirect($e->url));
}
try {
if (config('app.debug') === false) {
if ($this->shouldntReport($e)) {
return;
}
Mail::send([
'html' => 'emails.exception',
'text' => 'emails.exception-plain'
], [
'exception' => $e,
'appName' => config('app.name')
], function ($m) {
$m->to(
config('mail.contactsList.serviceweb'),
sprintf('%s Exception Handler', config('app.name'))
)->subject('Need help !, an error occured in your application, please check Logs ASAP');
});
}
// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
} catch (RuntimeException $e) {
// do nothing, mail send error, let laravel only handle this
} finally {
parent::report($e);
}
}
/**
* Register the exception handling callbacks for the application.
*

View file

@ -0,0 +1,241 @@
<?php
namespace App\Http\Controllers;
use App\Exceptions\CrudException;
use App\Http\Requests\CrudIndexRequest;
use App\Http\Requests\StoreCrudModel;
use App\Http\Requests\UpdateCrudModel;
use App\Http\Resources\CrudResource;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Str;
class CrudController extends Controller
{
/** @var \Illuminate\Database\Eloquent\Model */
protected static $modelClass = null;
// Eloquent callbacks
public function indexQuery(Builder $query): Builder
{
return $query;
}
public function storeModel(Model &$model)
{
}
public function storedModel(Model &$model)
{
}
public function updateModel(Model &$model)
{
}
public function updatedModel(Model &$model)
{
}
public function __construct()
{
// AUTH CONTROL TO ROOT POLICY
$this->authorizeResource(static::$modelClass);
$modelClass = static::$modelClass;
if (is_null($modelClass) or !class_exists($modelClass)) {
throw new CrudException(sprintf(
'%s needs the modelClass prop to be a class, %s %s given',
__CLASS__,
gettype($modelClass),
$modelClass
));
}
if (!is_subclass_of($modelClass, Model::class)) {
throw new CrudException(sprintf(
'%s needs the model prop to be a class the extends %s',
__CLASS__,
Model::class
));
}
if (!request()->ajax() and !app()->runningInConsole()) {
self::shareCrudModelsClassesToView();
$this->shareModelClassToView();
$this->shareModelTableToView();
$this->shareModelPropsToView();
}
}
public function index(CrudIndexRequest $request)
{
/** @var \Illuminate\Database\Eloquent\Builder */
$models = static::$modelClass::query();
// Search on crud Label column
if ($request->search) {
$model = static::$modelClass;
$col = (new $model())->crudLabelColumn;
$models = $models->where($col, 'LIKE', "%$request->search%");
}
// Sort columns
$tableName = (new static::$modelClass())->getTable();
if ($request->rst) {
Session::remove("crud.$tableName.sort_col");
Session::remove("crud.$tableName.sort_way");
}
if ($request->sort_col) {
Session::put("crud.$tableName.sort_col", $request->sort_col);
Session::save();
}
if ($request->sort_way) {
Session::put("crud.$tableName.sort_way", $request->sort_way);
Session::save();
}
if (
Session::has("crud.$tableName.sort_col") and
Session::has("crud.$tableName.sort_way") and
Schema::hasColumn($tableName, Session::get("crud.$tableName.sort_col"))
) {
$models = $models->orderBy(
Session::get("crud.$tableName.sort_col"),
Session::get("crud.$tableName.sort_way")
);
}
$models = $this->indexQuery($models);
/** @var \Illuminate\Pagination\AbstractPaginator */
$models = $models->paginate(config('crud.paginate', 15));
if ($request->ajax()) {
return response()->json(CrudResource::collection($models));
} else {
$models = $models->withQueryString();
return view('crud.index', compact('models'));
}
}
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
public function show(Model $model)
{
return view('crud.show', compact('model'));
}
public function create()
{
return view('crud.create');
}
public function store(StoreCrudModel $request)
{
$modelClass = static::$modelClass;
$model = new $modelClass();
$model->fill($request->validated());
$this->storeModel($model);
$model->saveOrFail();
$model->saveRelations($request);
$this->storedModel($model);
return \redirect()->route(sprintf('%s.edit', $model->getTable()), $model)
->with('success', trans(':model à bien été enregisté.', ['model' => $model->getModelName()]));
}
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
public function edit(Model $model)
{
return view('crud.edit', compact('model'));
}
public function update(UpdateCrudModel $request, Model $model)
{
$model->fill($request->validated());
$this->updateModel($model);
$model->saveOrFail();
$model->saveRelations($request);
$this->updatedModel($model);
return \redirect()->route(sprintf('%s.edit', $model->getTable()), $model)
->with('success', trans(':model à bien été enregisté.', ['model' => $model->getModelName()]));
}
public function destroy(Model $model)
{
if ($restrict = $model->gotHasManyRelationWithRestrictOnDelete()) {
return redirect()->back()
->with('error', trans(
':model n\'as pas pu être supprimé car il est encore associé avec des :associated',
[
'model' => $model->getModelName(),
'associated' => Str::plural($restrict)
]
));
}
if ($model->delete()) {
return \redirect()->route(sprintf('%s.index', $model->getTable()))
->with('warning', trans(':model à bien été supprimé.', ['model' => $model->getModelName()]));
} else {
return redirect()->back()
->with('error', trans(':model n\'as pas pu être supprimé', ['model' => $model->getModelName()]));
}
}
public function getModelClass()
{
return static::$modelClass;
}
/**
* Get the current model class
*
* @return void
*/
public static function shareCrudModelsClassesToView(): void
{
$models = [];
foreach (File::allFiles(\app_path('Http/Controllers')) as $file) {
$class = sprintf('App\Http\Controllers\%s', \substr($file->getFilename(), 0, -4));
if (is_subclass_of($class, CrudController::class) and $class::$modelClass) {
$models[] = $class::$modelClass;
}
}
View::share('modelsClasses', $models);
}
/**
* Get the current model class
*
* @return void
*/
private function shareModelClassToView(): void
{
View::share('modelClass', static::$modelClass);
}
/**
* Get the current model table
*
* @return void
*/
private function shareModelTableToView(): void
{
/** @var \Illuminate\Database\Eloquent\Model $model */
$model = static::$modelClass;
View::share('modelTable', (new $model())->getTable());
}
/**
* Get the current model properties
*
* @return void
*/
private function shareModelPropsToView(): void
{
$model = static::$modelClass;
View::share('props', (new $model())->getEditableProperties());
}
}

View file

@ -2,59 +2,11 @@
namespace App\Http\Controllers;
use App\Http\Requests\StoreProject;
use App\Http\Requests\UpdateProject;
use App\Models\Project;
use App\Models\Ticket;
class ProjectController extends Controller
class ProjectController extends CrudController
{
public function __construct()
{
$this->authorizeResource(Project::class, 'project');
}
public function index()
{
$projects = Project::all();
return view('project.index', compact('projects'));
}
public function create()
{
return view('project.create');
}
public function store(StoreProject $request)
{
$project = new Project($request->validated());
$project->saveOrFail();
return redirect()->route('project.index');
}
public function edit(Project $project)
{
return view('project.edit', [
'project' => $project
]);
}
public function update(UpdateProject $request, Project $project)
{
$project->fill($request->validated());
$project->saveOrFail();
return redirect()->back()->with('success', 'Modifications enregistrées !');
}
public function destroy(Project $project)
{
if (Ticket::where('project_id', $project->id)->count()) {
return redirect()->back()
->withErrors("Ce projet ne peut être supprimer, des tickets lui sont encore liés.");
}
$project->delete();
return redirect()->route('project.index');
}
/** @var Project */
protected static $modelClass = Project::class;
}

View file

@ -2,24 +2,30 @@
namespace App\Http\Controllers;
use App\Enums\Role;
use App\Enums\TicketStatus;
use App\Enums\TicketType;
use App\Events\NotifySubscriptions;
use App\Events\TicketStatusChanged;
use App\Http\Requests\ChangeTicketStatus;
use App\Http\Requests\CreateMessage;
use App\Http\Requests\SortTickets;
use App\Http\Requests\StoreTicket;
use App\Http\Requests\UpdateTicket;
use App\Jobs\SendTicketMentionMail;
use App\Mail\NewTicket;
use App\Mail\TicketMention;
use App\Models\Message;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\User;
use Exception;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
class TicketController extends Controller
@ -31,40 +37,99 @@ class TicketController extends Controller
public function index(SortTickets $request)
{
$tickets = Ticket::query();
if ($request->status) {
$tickets = $tickets->where('status', TicketStatus::archived());
} else {
$tickets = $tickets->where('status', '<>', TicketStatus::archived());
/** @var \App\Models\User */
$user = auth()->user();
$tickets = Ticket::with('project');
if (!$user->role->equals(Role::admin())) {
$tickets = $tickets->whereHas('project', function ($q) use ($user) {
$q->whereHas('users', function ($q) use ($user) {
$q->whereId($user->id);
});
});
}
if ($request->sort_col and $request->sort_way) {
switch ($request->sort_col) {
case 'id':
$tickets = $tickets->orderBy('id', $request->sort_way);
break;
case 'project':
$tickets = $tickets->orderBy('project_id', $request->sort_way);
break;
case 'created_at':
$tickets = $tickets->orderBy('created_at', $request->sort_way);
break;
case 'created_by':
$tickets = $tickets->orderBy('created_by', $request->sort_way);
break;
case 'type':
$tickets = $tickets->orderBy('type', $request->sort_way);
break;
case 'status':
$tickets = $tickets->orderBy('status', $request->sort_way);
break;
}
$tickets = $tickets->where('status', $request->status ? '=' : '<>', TicketStatus::archived());
if ($request->search) {
$tickets = $this->searchTicket($tickets, $request);
}
$tickets = $this->filterTickets($tickets, $request);
$tickets = $tickets->paginate();
return view('ticket.index', compact('tickets'));
$projects = Project::select('id', 'name')->orderBy('name')->get();
return view('ticket.index', compact('tickets', 'projects'));
}
private function filterTickets(Builder $tickets, Request $request): Builder
{
if ($request->rst) {
Session::remove('tickets.pr');
Session::remove('tickets.sort_col');
Session::remove('tickets.sort_way');
}
if ($request->prs) {
Session::remove('tickets.pr');
}
if ($request->sort_col) {
Session::put('tickets.sort_col', $request->sort_col);
}
if ($request->sort_way) {
Session::put('tickets.sort_way', $request->sort_way);
}
if ($request->pr) {
Session::put('tickets.pr', $request->pr);
}
Session::save();
if (Session::has('tickets.pr')) {
$tickets = $tickets->where('project_id', Session::get('tickets.pr'));
}
if (
Session::has('tickets.sort_col') and
Session::has('tickets.sort_way') and
in_array(Session::get('tickets.sort_col'), [
'id', 'project', 'created_at', 'created_by',
'type', 'status'
])
) {
$tickets = $tickets->orderBy(
Session::get('tickets.sort_col'),
Session::get('tickets.sort_way')
);
}
return $tickets;
}
private function searchTicket(Builder $tickets, Request $request): Builder
{
$words = explode(' ', $request->search);
return $tickets->where(function ($q) use ($words) {
foreach ($words as $word) {
if (!strlen($word)) {
continue;
}
$q = $q->where(function ($q) use ($word) {
$s = collect(TicketStatus::toArray())->filter(function (TicketStatus $t) use ($word) {
return strpos(strtolower($t->label), $word) !== false;
})->map(function (TicketStatus $ticket) {
return $ticket->value;
});
$t = collect(TicketType::toArray())->filter(function (TicketType $t) use ($word) {
return strpos(strtolower($t->label), $word) !== false;
})->map(function (TicketType $ticket) {
return $ticket->value;
});
$q->where('text', 'LIKE', "%$word%")
->orWhereIn('status', $s)
->orWhereIn('type', $t)
->orWhere('file', 'LIKE', "%$word%")
->orWhere('file2', 'LIKE', "%$word%")
->orWhereHas('user', function ($q) use ($word) {
$q->where('name', 'LIKE', "%$word%");
});
});
}
});
}
public function show(Ticket $ticket)
@ -76,7 +141,13 @@ class TicketController extends Controller
public function create()
{
$projects = Project::all();
$user = Auth::user();
if ($user->role === 1) {
$projects = Project::orderBy('name')->get();
} else {
$projects = $user->projects;
}
if (!count($projects)) {
return redirect()->route('project.create')
->with('warning', 'Vous devez tout d\'abord créer un projet pour y ajouter un ticket');
@ -97,8 +168,15 @@ class TicketController extends Controller
$ticket->file2 = Ticket::saveFile($project, $ticket, $ticket->type, $request->file_ticket2);
}
if ($request->type === TicketType::evolution()) {
$ticket->status = TicketStatus::estimateTodo();
}
$ticket->user()->associate(auth()->user());
$ticket->save();
$ticket->saveOrFail();
/** @var \App\Models\User */
$user = auth()->user();
$user->subscriptions()->toggle($ticket->id);
if (config('mail.MAIL_ADMIN')) {
Mail::to(config('mail.MAIL_ADMIN'))->send(new NewTicket($ticket));
@ -106,7 +184,7 @@ class TicketController extends Controller
throw new Exception('Missing MAIL_ADMIN in .env');
}
return redirect()->route('home')->with('success', 'Ticket enregistré !');
return redirect()->route('ticket.show', $ticket)->with('success', 'Ticket enregistré !');
}
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
@ -154,7 +232,18 @@ class TicketController extends Controller
$message = new Message($request->validated());
$message->user()->associate(auth()->user());
$message->ticket()->associate($ticket);
$message->save();
$message->saveOrFail();
/** @var \App\Models\User */
$user = auth()->user();
if ($request->subscribe) {
if (!$user->subscriptions()->find($ticket->id)) {
$user->subscriptions()->attach($ticket->id);
}
} elseif ($user->subscriptions()->find($ticket->id)) {
$user->subscriptions()->detach($ticket->id);
}
if ($request->file_message) {
$projectSlug = Str::slug($ticket->project->name);
@ -174,6 +263,25 @@ class TicketController extends Controller
])
));
// find @user + send mail si un seul user est trouvé
$matches = [];
\preg_match_all('/@\w*/', $message->message, $matches);
$matches = collect($matches[0] ?? [])->unique();
foreach ($matches as $pseudo_usertag) {
try {
$user = User::where('name', 'LIKE', '%' . ltrim($pseudo_usertag, '@') . '%')
->whereDoesntHave('subscriptions', function ($q) use ($ticket) {
$q->where('id', $ticket->id);
})->firstOrFail();
SendTicketMentionMail::dispatchAfterResponse(
$user,
new TicketMention($ticket, $message->message)
);
} catch (ModelNotFoundException $e) {
continue;
}
}
return back();
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
class UserController extends CrudController
{
/** @var User */
protected static $modelClass = User::class;
}

View file

@ -14,6 +14,7 @@ class Kernel extends HttpKernel
* @var array
*/
protected $middleware = [
\Illuminate\Session\Middleware\StartSession::class,
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Fruitcake\Cors\HandleCors::class,
@ -32,7 +33,6 @@ class Kernel extends HttpKernel
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,

View file

@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use App\Enums\Role;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@ -17,8 +18,9 @@ class IsAdmin
*/
public function handle(Request $request, Closure $next)
{
if (Auth::user()->role === 1)
if (Auth::user()->role->equals(Role::admin())) {
return $next($request);
}
return redirect()->route('home');
}
}

View file

@ -2,11 +2,23 @@
namespace App\Http\Requests;
use App\Enums\Role;
use App\Enums\TicketStatus;
use Illuminate\Foundation\Http\FormRequest;
class ChangeTicketStatus extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return auth()->check() and
auth()->user()->role->equals(Role::admin());
}
/**
* Get the validation rules that apply to the request.
*

View file

@ -2,10 +2,25 @@
namespace App\Http\Requests;
use App\Enums\Role;
use Illuminate\Foundation\Http\FormRequest;
class CreateMessage extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
$this->merge([
'subscribe' => $this->subscribe ? true : false
]);
return auth()->check() and (auth()->user()->role->equals(Role::admin()) or
auth()->user()->projects->contains(request()->route()->ticket->project_id));
}
/**
* Get the validation rules that apply to the request.
*
@ -14,7 +29,8 @@ class CreateMessage extends FormRequest
public function rules()
{
return [
'message' => 'required|min:20',
'subscribe' => 'required|boolean',
'message' => 'required|min:' . (auth()->user()->role->equals(Role::admin()) ? '5' : '20'),
'file_message' => 'sometimes|mimes:jpeg,jpg,png,pdf|max:2048'
];
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CrudIndexRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return auth()->check();
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'search' => 'nullable|string'
];
}
}

View file

@ -0,0 +1,79 @@
<?php
namespace App\Http\Requests;
use App\Enums\CrudAction;
use App\Enums\CrudType;
use App\Exceptions\CrudException;
use App\Http\Controllers\CrudController;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Http\FormRequest;
use App\Traits\IsCrudModel;
class CrudRequest extends FormRequest
{
/** @var array */
protected $rules = [];
/**
* Get rules for a specific action
*
* @param CrudAction $action
* @return array
*/
public function getRulesForAction(CrudAction $action): array
{
$props = $this->getCurrentModel()::getEditableProperties();
$propsRules = [];
foreach ($this->getCurrentModel()::getRules() as $k => $rules) {
$propsRules[$k] = [];
$propIsAllowedOnUpdateAction = in_array($action, $props[$k]['actions']);
foreach ($rules as $rule) {
// dont copy required if prop is not allowed on update
if (!$propIsAllowedOnUpdateAction and is_string($rule) and $rule == 'required') {
continue;
}
if (!in_array($rule, $propsRules[$k])) {
$propsRules[$k][] = $rule;
}
// handle password
if (
$props[$k]['type']->equals(CrudType::password()) and
!$this->{$k} or (is_string($this->{$k}) and !\strlen($this->{$k}))
) {
$this->offsetUnset($k);
}
// handle boolean
if ($props[$k]['type']->equals(CrudType::boolean())) {
$this->merge([$k => $this->{$k} ? 1 : 0]);
}
// handle belongsToMany
if ($props[$k]['type']->equals(CrudType::belongsToMany())) {
$propsRules["$k.*"][0] = 'exists:' .
(new $props[$k]['belongsToMany']())->getTable() . ',id';
}
}
}
return $propsRules;
}
/**
* Returns the first parameter that is a crud model
*
* @return string \Illuminate\Database\Eloquent\Model class name
* @throws \App\Exceptions\CrudException if the request does not have a crud model parameter
*/
protected function getCurrentModel(): string
{
if (
!is_object($this->route()->controller) or
!$this->route()->controller instanceof CrudController
) {
throw new CrudException(sprintf(
'Request `%s` needs to be bound on route that use a CrudController',
get_class($this)
));
}
return $this->route()->controller->getModelClass();
}
}

View file

@ -25,6 +25,7 @@ class SortTickets extends FormRequest
public function rules()
{
return [
'search' => 'nullable|string',
'sort_way' => 'sometimes|in:asc,desc',
'status' => [
'sometimes',
@ -37,6 +38,7 @@ class SortTickets extends FormRequest
}
}
],
'pr' => 'sometimes|int|exists:projects,id'
];
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use App\Enums\CrudAction;
class StoreCrudModel extends CrudRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
$model = $this->getCurrentModel();
/** @var \App\Models\User|null $user */
$user = auth()->user();
$authorized = auth()->check() and $user->can('create', $model);
if ($authorized) {
$this->rules = $this->getRulesForAction(CrudAction::create());
}
return $authorized;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return $this->rules;
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Enums\Role;
use Illuminate\Foundation\Http\FormRequest;
class StoreProject extends FormRequest
@ -13,7 +14,8 @@ class StoreProject extends FormRequest
*/
public function authorize()
{
return auth()->check();
return auth()->check() and
auth()->user()->role->equals(Role::admin());
}
/**

View file

@ -15,12 +15,6 @@ class StoreTicket extends FormRequest
*/
public function authorize()
{
if (auth()->user()->project_id) {
$this->merge([
'project_id' => auth()->user()->project_id,
'status' => TicketStatus::new()->value,
]);
}
return auth()->check();
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use App\Enums\CrudAction;
class UpdateCrudModel extends CrudRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
$model = $this->getCurrentModel();
/** @var \App\Models\User|null $user */
$user = auth()->user();
$authorized = auth()->check() and $user->can('update', $model);
if ($authorized) {
$this->rules = $this->getRulesForAction(CrudAction::update());
}
return $authorized;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return $this->rules;
}
}

View file

@ -2,7 +2,6 @@
namespace App\Http\Requests;
class UpdateProject extends StoreProject
{
}

View file

@ -2,20 +2,13 @@
namespace App\Http\Requests;
use App\Enums\Role;
use App\Enums\TicketStatus;
class UpdateTicket extends StoreTicket
{
public function authorize()
{
if (auth()->user()->project_id) {
$this->merge([
'project_id' => auth()->user()->project_id,
'status' => TicketStatus::new()->value,
]);
}
return auth()->check() or
auth()->user()->role === 1 or
request()->route()->ticket->created_by === auth()->user()->id;
return auth()->check() and (auth()->user()->role->equals(Role::admin()));
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class CrudResource extends JsonResource
{
/**
* Transform the resource collection into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function toArray($request)
{
return [
'label' => $this->crudLabel,
'value' => $this->crudValue
];
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Http\Resources;
use App\Models\User;
use Illuminate\Http\Resources\Json\ResourceCollection;
class UserLabeledResource extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
public function toArray($request)
{
return $this->collection->mapWithKeys(function (User $user, $key) {
return [
$key => [
'label' => sprintf('%d %s', $user->id, $user->name),
'key' => $user->id
]
];
});
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Jobs;
use App\Mail\TicketMention;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class SendTicketMentionMail implements ShouldQueue
{
use Dispatchable;
use SerializesModels;
/** @var User */
private $user;
/** @var TicketMention */
private $mail;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(User $user, TicketMention $mail)
{
$this->user = $user;
$this->mail = $mail;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
Mail::to($this->user->email)->send($this->mail);
Log::debug(sprintf('Mention mail sent to %s', $this->user->email));
}
}

View file

@ -0,0 +1,106 @@
<?php
namespace App\Lib\PolicySystem;
use App\Models\User;
use App\Lib\PolicySystem\StaticRules as SR;
use Illuminate\Auth\Access\Response;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
class RootPolicy
{
protected $policy = null;
protected $policies = null;
public function __construct()
{
$cls = \explode('\\', \get_called_class());
$this->policy = \strtolower(\str_replace('Policy', '', \end($cls)));
}
protected function actionPolicy(string $method, string $policy, User $user, Model $model = null)
{
$this->setPolicies($user, $model);
$ctx = [$policy, $user, $model];
if (!$this->hasPolicy($policy)) {
$m = __('Unhandled policy ":policy"', ['policy' => $policy]);
Log::critical($m, $ctx);
return $this->deny($m);
}
if (!is_array($this->policies[$policy])) {
$m = __('Policy ":policy" has to be array', ['policy' => $policy]);
Log::critical($m, $ctx);
return $this->deny($m);
}
// Policy method exists
if (!array_key_exists($method, $this->policies[$policy])) {
$m = __('Unhandled policy ":policy" for action ":action"', ['policy' => $policy, 'action' => $method]);
Log::critical($m, $ctx);
return $this->deny($m);
}
// Call policy method
if (Sr::handle($this->policies[$policy][$method], $user, $model)) {
return $this->allow();
} else {
$m = __('Policy ":policy" for action ":action" denied :onmodel', [
'policy' => $policy,
'action' => $method,
'onmodel' => $model ? get_class($model) : ''
]);
if (config('app.policyDebug')) {
Log::debug(sprintf("%s user => %s model => %s", $m, $user, $model), $ctx);
}
return $this->deny($m);
}
$m = __('Unhandled policy case on resource ":method"', ['method' => $method]);
Log::critical($m, $ctx);
return $this->deny();
}
/**
** Do the magic calling Root policy from any policy
*/
public function __call($method, $args)
{
return call_user_func_array([$this, 'actionPolicy'], array_merge([$method, $this->policy], $args));
}
private function hasPolicy(string $policy)
{
return array_key_exists($policy, $this->policies);
}
/**
* Allow action for the user
*
* @param string|null $message
* @return \Illuminate\Auth\Access\Response
*/
protected function allow(?string $message = null)
{
return Response::allow($message);
}
/**
* Deny action for the user
*
* @param string|null $message
* @param mixed $code
* @return \Illuminate\Auth\Access\Response
*/
protected function deny(?string $message = null, $code = null)
{
if (!$message) {
$message = __('This action is not allowed to you for this resource.');
}
return Response::deny($message, $code);
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace App\Lib\PolicySystem;
use App\Models\User;
use Exception;
use Closure;
use Illuminate\Database\Eloquent\Model;
/**
* Generic Policiy Static Rules
*! Rules has to return boolean value
*/
abstract class StaticRules
{
/**
* Make a rule closure
*
* @param string $rulename
* @param mixed ...$args
* @return Closure
*/
public static function make(string $rulename, ...$args)
{
if (!method_exists(static::class, $rulename)) {
throw new Exception(sprintf('Unknown rule %s in Policies StaticRules', $rulename));
}
return function () use ($rulename, $args) {
return call_user_func_array([static::class, $rulename], $args);
};
}
public static function and(array $ruleClosures)
{
return function (User $user, Model $model = null) use ($ruleClosures) {
$nbRules = count($ruleClosures);
$out = true;
$i = 0;
do {
if (!($out = (self::handle($ruleClosures[$i++], $user, $model) && $out))) {
break;
}
} while ($i < $nbRules);
return $out;
};
}
public static function or(array $ruleClosures)
{
return function (User $user, Model $model = null) use ($ruleClosures) {
$nbRules = count($ruleClosures);
$out = false;
$i = 0;
do {
if ($out = (self::handle($ruleClosures[$i++], $user, $model) || $out)) {
break;
}
} while ($i < $nbRules);
return $out;
};
}
public static function handle($rule, User $user, Model $model = null)
{
if (is_array($rule)) {
$nbRule = count($rule);
$out = false;
$i = 0;
while ($i < $nbRule) {
if ($out = self::handle($rule[$i++], $user, $model)) {
break;
}
}
return $out;
}
if (!is_callable($rule)) {
throw new Exception('Rule has to ba callable.');
}
return (bool)$rule($user, $model);
}
protected static function anyone()
{
return true;
}
}

41
app/Lib/Utils.php Normal file
View file

@ -0,0 +1,41 @@
<?php
namespace App\Lib;
class Utils
{
public static function plainToHtml(string $text)
{
$text = e($text);
// url replace and email replace
$text = preg_replace_callback(
'/(?P<email>[^\s@]+@([^\s@.,]+\.)+[^\s@.,]{2,})|(?P<user>@\w*)|(?P<url>(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*))/',
function ($matches) {
$match = collect($matches)->first() ?? '';
if (filter_var($match, FILTER_VALIDATE_EMAIL)) {
return strlen($match) ? "<a href=\"mailto:$match\">$match</a>" : $match;
} elseif (isset($matches['user']) and strlen($matches['user'])) {
return sprintf(
'<span class="text-primary fw-bold font-weight-bold">%s</span>',
$matches['user']
);
} else {
$url = $match;
if (\strpos($url, 'http') === false and \strpos($url, 'https') === false) {
$url = sprintf('http://%s', $url);
}
$fsligthbox = self::isImageFile($url) ? 'data-fslightbox="gallery"' : '';
return strlen($match) ? "<a $fsligthbox href=\"$url\" target=\"_blank\">$match</a>" : $match;
}
},
$text
);
return nl2br($text);
}
public static function isImageFile(string $url): bool
{
$m = [];
return \preg_match('/(\.png|\.jpg|\.jpeg|\.gif)$/', $url, $m) === 1;
}
}

View file

@ -21,7 +21,7 @@ class SendNotificationMail
public function handle(NotifySubscriptions $event)
{
foreach ($event->ticket->subscriptions as $sub) {
if ($sub->email !== Auth::user()->email) {
if (Auth::user() and $sub->email !== Auth::user()->email) {
Mail::to($sub->email)->send(new TicketUpdate($event->ticket, $event->corpsMaj));
}
}

View file

@ -31,6 +31,6 @@ class NewTicket extends Mailable
public function build()
{
$ticket = $this->ticket;
return $this->subject("[SupportWeb] Nouveau ticket n°" . $ticket->id)->view('mails.new-ticket', compact('ticket'));
return $this->subject("[SupportWeb] Nouveau ticket n°" . $ticket->id)->view('emails.new-ticket', compact('ticket'));
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Mail;
use App\Lib\Utils;
use App\Models\Ticket;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class TicketMention extends Mailable
{
use Queueable;
use SerializesModels;
/** @var Ticket */
private $ticket;
/** @var string */
private $corpsMaj;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(Ticket $ticket, string $corpsMaj)
{
$this->ticket = $ticket;
$this->corpsMaj = $corpsMaj;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$ticket = $this->ticket;
$corpsMaj = Utils::plainToHtml($this->corpsMaj);
return $this->subject("[SupportWeb] Vous avez été mentionné sur le ticket n° $ticket->id")
->view('emails.ticket-mention', compact('ticket', 'corpsMaj'));
}
}

View file

@ -2,6 +2,8 @@
namespace App\Mail;
use App\Lib\Utils;
use App\Models\Ticket;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
@ -9,16 +11,21 @@ use Illuminate\Queue\SerializesModels;
class TicketUpdate extends Mailable
{
use Queueable, SerializesModels;
use Queueable;
use SerializesModels;
private $ticket, $corpsMaj;
/** @var Ticket */
private $ticket;
/** @var string */
private $corpsMaj;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct($ticket, $corpsMaj)
public function __construct(Ticket $ticket, string $corpsMaj)
{
$this->ticket = $ticket;
$this->corpsMaj = $corpsMaj;
@ -32,7 +39,7 @@ class TicketUpdate extends Mailable
public function build()
{
$ticket = $this->ticket;
$corpsMaj = $this->corpsMaj;
return $this->subject("[SupportWeb] Le ticket n°" . $ticket->id . " à été mis à jour")->view('mails.ticket-update', compact('ticket', 'corpsMaj'));
$corpsMaj = Utils::plainToHtml($this->corpsMaj);
return $this->subject("[SupportWeb] Le ticket n°" . $ticket->id . " à été mis à jour")->view('emails.ticket-update', compact('ticket', 'corpsMaj'));
}
}

View file

@ -2,6 +2,10 @@
namespace App\Models;
use App\Enums\CrudAction;
use App\Enums\CrudType;
use App\Enums\Role;
use App\Traits\IsCrudModel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\File;
@ -10,9 +14,52 @@ use Illuminate\Support\Str;
class Project extends Model
{
use HasFactory;
use IsCrudModel;
protected $crudLabelColumn = 'name';
protected $crudValueColumn = 'id';
protected $fillable = ['name'];
public function __construct($attributes = [])
{
self::$editableProperties = [
'name' => [
'label' => trans('Nom'),
'type' => CrudType::string(),
'validate' => [
'string',
'max:255'
],
'required' => true,
'nullable' => false,
'actions' => [
CrudAction::viewAny(),
CrudAction::view(),
CrudAction::create(),
CrudAction::update(),
]
],
'users' => [
'label' => trans('Utilisateurs'),
'type' => CrudType::belongsToMany(),
'belongsToMany' => User::class,
'belongsToManyLabel' => 'name',
'readonly' => function () {
return auth()->user()->role->equals(Role::admin()) ? false : true;
},
'actions' => [
CrudAction::viewAny(),
CrudAction::view(),
CrudAction::create(),
CrudAction::update(),
]
]
];
parent::__construct($attributes);
}
protected static function booted()
{
static::created(function (Project $project) {
@ -39,6 +86,11 @@ class Project extends Model
});
}
public function users()
{
return $this->belongsToMany(User::class);
}
public function tickets()
{
return $this->hasMany(Ticket::class, 'project_id');

View file

@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\Pivot;
class ProjectUser extends Pivot
{
public $timestamps = false;
}

View file

@ -30,6 +30,12 @@ class Ticket extends Model
protected static function booted()
{
static::creating(function (Ticket $ticket) {
// If evolution force the ticket status on estimateTodo
if ($ticket->type->equals(TicketType::evolution())) {
$ticket->status = TicketStatus::estimateTodo();
}
});
static::updating(function (Ticket $ticket) {
$oldStatus = Ticket::where('id', $ticket->id)->first()->status;
if (!$ticket->status->equals($oldStatus)) {

View file

@ -2,14 +2,29 @@
namespace App\Models;
use App\Enums\CrudAction;
use App\Enums\CrudType;
use App\Enums\Role;
use App\Traits\IsCrudModel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Kwaadpepper\Enum\Traits\CastsEnums;
class User extends Authenticatable
{
use HasFactory;
use Notifiable;
use CastsEnums;
use IsCrudModel;
protected $enumCasts = [
'role' => Role::class
];
protected $crudLabelColumn = 'name';
protected $crudValueColumn = 'id';
/**
* The attributes that are mass assignable.
@ -41,6 +56,72 @@ class User extends Authenticatable
'email_verified_at' => 'datetime',
];
public function __construct($attributes = [])
{
self::$editableProperties = [
'name' => [
'label' => trans('Nom'),
'type' => CrudType::string(),
'validate' => [
'string',
'max:255'
],
'required' => true,
'nullable' => false,
'actions' => [
CrudAction::viewAny(),
CrudAction::view(),
CrudAction::create(),
CrudAction::update(),
]
],
'email' => [
'label' => trans('Email'),
'type' => CrudType::email(),
'required' => true,
'nullable' => false,
'validate' => [
'max:255'
],
'actions' => [
CrudAction::viewAny(),
CrudAction::view(),
CrudAction::create(),
CrudAction::update(),
]
],
'role' => [
'label' => trans('Role'),
'type' => CrudType::enum(),
'enum' => Role::class,
'required' => true,
'nullable' => false,
'actions' => [
CrudAction::viewAny(),
CrudAction::view(),
CrudAction::create(),
CrudAction::update(),
]
],
'projects' => [
'label' => trans('Projets'),
'type' => CrudType::belongsToMany(),
'belongsToMany' => Project::class,
'belongsToManyLabel' => 'name',
'readonly' => function () {
return auth()->user()->role->equals(Role::admin()) ? false : true;
},
'actions' => [
CrudAction::viewAny(),
CrudAction::view(),
CrudAction::create(),
CrudAction::update(),
]
]
];
parent::__construct($attributes);
}
public function tickets()
{
return $this->hasMany(Ticket::class, 'created_by');
@ -56,8 +137,8 @@ class User extends Authenticatable
return $this->belongsToMany(Ticket::class, 'subscriptions');
}
public function project()
public function projects()
{
return $this->belongsTo(Project::class);
return $this->belongsToMany(Project::class);
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Lib\PolicySystem\RootPolicy;
use App\Lib\PolicySystem\StaticRules as Sr;
use App\Policies\Rules\UserStaticRules as USR;
use App\Policies\Rules\UserRoleRules as URR;
use Illuminate\Database\Eloquent\Model;
class AppPolicies extends RootPolicy
{
/**
* Init policies on call
*
* @param User $user
* @param Model $model
* @return void
*/
public function setPolicies(User $user, Model $model = null)
{
/**
* This first rule to return true in a policy action authorises the action
*/
$this->policies = [
'project' => [
'viewAny' => URR::make('userIsAdmin', $user),
'view' => URR::make('userIsAdmin', $user),
'update' => URR::make('userIsAdmin', $user),
'create' => URR::make('userIsAdmin', $user),
'delete' => URR::make('userIsAdmin', $user)
],
'user' => [
'viewAny' => URR::make('userIsAdmin', $user),
'view' => URR::make('userIsAdmin', $user),
'update' => URR::make('userIsAdmin', $user),
'create' => URR::make('userIsAdmin', $user),
'delete' => URR::make('userIsAdmin', $user)
],
];
}
}

View file

@ -2,98 +2,11 @@
namespace App\Policies;
use App\Models\Project;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class ProjectPolicy
class ProjectPolicy extends AppPolicies
{
use HandlesAuthorization;
/**
* Determine whether the user can view any models.
*
* @param \App\Models\User $user
* @return mixed
*/
public function viewAny(User $user)
{
return $user->role === 1;
}
/**
* Determine whether the user can view the model.
*
* @param \App\Models\User $user
* @param \App\Models\Project $project
* @return mixed
*/
//phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
public function view(User $user, Project $project)
{
return $user->role === 1;
}
/**
* Determine whether the user can create models.
*
* @param \App\Models\User $user
* @return mixed
*/
public function create(User $user)
{
return $user->role === 1;
}
/**
* Determine whether the user can update the model.
*
* @param \App\Models\User $user
* @param \App\Models\Project $project
* @return mixed
*/
//phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
public function update(User $user, Project $project)
{
return $user->role === 1;
}
/**
* Determine whether the user can delete the model.
*
* @param \App\Models\User $user
* @param \App\Models\Project $project
* @return mixed
*/
//phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
public function delete(User $user, Project $project)
{
return $user->role === 1;
}
/**
* Determine whether the user can restore the model.
*
* @param \App\Models\User $user
* @param \App\Models\Project $project
* @return mixed
*/
//phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
public function restore(User $user, Project $project)
{
return $user->role === 1;
}
/**
* Determine whether the user can permanently delete the model.
*
* @param \App\Models\User $user
* @param \App\Models\Project $project
* @return mixed
*/
//phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
public function forceDelete(User $user, Project $project)
{
return $user->role === 1;
}
// Handle delete has tickets
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Policies\Rules;
use App\Enums\Role;
use App\Models\User;
use App\Lib\PolicySystem\StaticRules;
/**
* Userlevel Policiy Static Rules
*! Rules has to return boolean value
*/
abstract class UserRoleRules extends StaticRules
{
protected static function userIsAdmin(User $user)
{
return $user->role->equals(Role::admin());
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Policies\Rules;
use App\Models\User;
use App\Lib\PolicySystem\StaticRules;
/**
* Userlevel Policiy Static Rules
*! Rules has to return boolean value
*/
abstract class UserStaticRules extends StaticRules
{
protected static function usersAreTheSame(User $user, User $userToCheck)
{
return ($user->id == $userToCheck->id) != 0;
}
protected static function usersAreNotTheSame(User $user, User $userToCheck)
{
return !self::usersAreTheSame($user, $userToCheck);
}
protected static function everyOne()
{
return true;
}
protected static function noOne()
{
return false;
}
}

View file

@ -2,6 +2,7 @@
namespace App\Policies;
use App\Enums\Role;
use App\Enums\TicketStatus;
use App\Models\Ticket;
use App\Models\User;
@ -59,7 +60,7 @@ class TicketPolicy
*/
public function update(User $user, Ticket $ticket)
{
return Auth::user()->role === 1 or $ticket->created_by == $user->id;
return Auth::user()->role->equals(Role::admin()) or $user->projects->contains($ticket->project_id);
}
/**
@ -84,7 +85,7 @@ class TicketPolicy
*/
public function restore(User $user, Ticket $ticket)
{
return Auth::user()->role === 1 or $ticket->created_by == $user->id;
return Auth::user()->role->equals(Role::admin()) or $user->projects->contains($ticket->project_id);
}
/**
@ -102,7 +103,7 @@ class TicketPolicy
public function changeStatus(User $user)
{
return $user->role === 1;
return $user->role->equals(Role::admin());
}
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed
@ -114,7 +115,16 @@ class TicketPolicy
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed
public function createMessage(User $user, Ticket $ticket)
{
if ($user->role !== 1 and $ticket->created_by !== $user->id) {
//can create if
//role == 1
// OR project_id IN user->projects
if (
!(
$user->role->equals(Role::admin())
or $user->projects->contains($ticket->project_id) )
)
{
return Response::deny(trans('Vous ne pouvez modifier un ticket qui ne vous appartient pas.'));
}
return $ticket->status->equals(TicketStatus::archived()) ?

View file

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
use Illuminate\Auth\Access\HandlesAuthorization;
class UserPolicy extends AppPolicies
{
use HandlesAuthorization;
}

View file

@ -2,7 +2,11 @@
namespace App\Providers;
use App\Enums\TicketStatus;
use App\Models\Ticket;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@ -25,8 +29,22 @@ class AppServiceProvider extends ServiceProvider
public function boot()
{
Paginator::useBootstrap();
if(config('app.env') !== 'local') {
\URL::forceScheme('https');
if (config('app.env') !== 'local') {
URL::forceScheme('https');
}
View::share('ticketsNew', Ticket::where('status', TicketStatus::new())->count());
View::share('ticketsInProgress', Ticket::whereNotIn(
'status',
[
TicketStatus::new(),
TicketStatus::estimateTodo(),
TicketStatus::estimateValidated(),
TicketStatus::toPlan(),
TicketStatus::planned(),
TicketStatus::production(),
TicketStatus::archived()
]
)->count());
}
}

View file

@ -15,10 +15,7 @@ class AuthServiceProvider extends ServiceProvider
*
* @var array
*/
protected $policies = [
Project::class => ProjectPolicy::class,
Ticket::class => TicketPolicy::class
];
protected $policies = [];
/**
* Register any authentication / authorization services.

View file

@ -2,6 +2,8 @@
namespace App\Providers;
use App\Models\Project;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
@ -47,6 +49,8 @@ class RouteServiceProvider extends ServiceProvider
->namespace($this->namespace)
->group(base_path('routes/web.php'));
});
Route::model('user', User::class);
Route::model('project', Project::class);
}
/**

View file

@ -0,0 +1,61 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider;
class TranslationServiceProvider extends ServiceProvider
{
/**
* Bootstrap the application services.
*
* @return void
*/
public function boot()
{
$translations = collect();
foreach ($this->collectLocalesStrings() as $locale) { // suported locales
Cache::rememberForever(sprintf('translations.%s', $locale), function () use ($locale) {
$translations = [
'php' => $this->phpTranslations($locale),
'json' => $this->jsonTranslations($locale),
];
return $translations;
});
}
}
private function collectLocalesStrings()
{
return collect(File::allFiles(resource_path('lang/')))->flatMap(function ($file) {
if ($file->getRelativePath()) {
return [$file->getRelativePath() => ''];
}
})->keys();
}
private function phpTranslations($locale)
{
$path = resource_path("lang/$locale");
return collect(File::allFiles($path))->flatMap(function ($file) use ($locale) {
$key = ($translation = $file->getBasename('.php'));
return [$key => trans($translation, [], $locale)];
});
}
private function jsonTranslations($locale)
{
$path = resource_path("lang/$locale.json");
if (is_string($path) && is_readable($path)) {
return json_decode(file_get_contents($path), true);
}
return [];
}
}

516
app/Traits/IsCrudModel.php Normal file
View file

@ -0,0 +1,516 @@
<?php
namespace App\Traits;
use App\Enums\CrudAction;
use App\Enums\CrudType;
use App\Exceptions\CrudException;
use App\Http\Requests\CrudRequest;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Kwaadpepper\Enum\BaseEnum;
trait IsCrudModel
{
protected static $editableProperties = [];
private static $schemaCrud = [
'label' => null,
'placeholder' => '',
'type' => null,
'enum' => null,
'nullable' => false,
'readonly' => false,
'disabled' => false,
'required' => false,
'validate' => [],
'rules' => [],
'actions' => [],
'belongsTo' => null,
'belongsToMany' => null,
'getAttribute' => null,
'setAttribute' => null
];
// OVERLOAD MODEL FUNCTION
public function __get($key)
{
if (
isset(static::$editableProperties[$key]['getAttribute']) and
is_callable(static::$editableProperties[$key]['getAttribute'])
) {
return static::$editableProperties[$key]['getAttribute']($this);
}
return parent::__get($key);
}
// OVERLOAD MODEL FUNCTION
public function __set($key, $value)
{
if (
isset($this::$editableProperties[$key]['setAttribute']) and
is_callable($this::$editableProperties[$key]['setAttribute'])
) {
return $this::$editableProperties[$key]['setAttribute']($this, $value);
}
return parent::__set($key, $value);
}
// OVERLOAD MODEL FUNCTION
public function fill(array $attributes)
{
parent::fill($attributes);
foreach ($attributes as $k => $attribute) {
if (
isset(static::$editableProperties[$k]['setAttribute']) and
is_callable(static::$editableProperties[$k]['setAttribute'])
) {
$this->{$k} = $attribute;
}
}
}
public function saveRelations(CrudRequest $request)
{
$validated = $request->validated();
$props = self::getEditableProperties();
foreach ($props as $k => $prop) {
if ($prop['belongsToMany'] and \is_array($validated[$k])) {
$this->{$k}()->sync($validated[$k]);
}
}
}
/**
* Has this model Has many relations with delete restrict ?
*
* @return bool|string
*/
public function gotHasManyRelationWithRestrictOnDelete()
{
$sm = DB::getDoctrineSchemaManager();
foreach (self::getRelationships() as $relation => $type) {
if ($type[0] === 'HasMany') {
$table = (new $type[1]())->getTable();
/** @var \Doctrine\DBAL\Schema\ForeignKeyConstraint $foreignKey */
foreach ($sm->listTableForeignKeys($table) as $foreignKey) {
// /vendor/doctrine/dbal/src/Schema/ForeignKeyConstraint.php onEvent()
if ($foreignKey->onDelete() === 'CASCADE') {
// allow only CASCADE action, otherwise it would break the app
continue;
}
return class_basename($type[1]);
}
}
}
return false;
}
/**
* Get the crud label
*/
public function getCrudLabelAttribute()
{
return $this->{$this->crudLabelColumn};
}
/**
* Get the crud value
*/
public function getCrudValueAttribute()
{
if (is_null($this->crudValueColumn)) {
throw new CrudException(sprintf(
'[\'belongsTo\'] model `%s` must have `crudValueColumn` property',
get_class($this),
CrudAction::class
));
}
return $this->{$this->crudValueColumn};
}
/**
* Get the crud label column
*/
public function getCrudLabelColumnAttribute()
{
if (is_null($this->crudLabelColumn)) {
throw new CrudException(sprintf(
'[\'belongsTo\'] model `%s` must have `crudLabelColumn` property',
get_class($this),
CrudAction::class
));
}
return $this->crudLabelColumn;
}
/**
* Get the crud value column
*/
public function getCrudValueColumnAttribute()
{
return $this->crudValueColumn;
}
/**
* Get the model validation rules
*
* @return array
*/
public static function getRules(): array
{
return collect(static::$editableProperties)->mapWithKeys(function ($item, $key) {
$item = \array_merge(self::$schemaCrud, $item);
$defaultRules = [];
if (!$item['nullable']) {
$defaultRules[] = 'required';
} elseif ($item['required']) {
$defaultRules[] = 'required';
} elseif ($item['readonly']) {
$defaultRules[] = 'prohibited';
}
switch ($item['type']) {
case CrudType::boolean():
$defaultRules[] = 'boolean';
break;
case CrudType::email():
$defaultRules[] = 'email:rfc,dns';
break;
case CrudType::belongsTo():
$defaultRules[] = 'integer';
$defaultRules[] = 'min:0';
break;
case CrudType::int():
$defaultRules[] = 'integer';
break;
case CrudType::belongsToMany():
$defaultRules[] = 'array';
break;
case CrudType::password():
// phpcs:ignore Generic.Files.LineLength.TooLong
$defaultRules[] = "regex:/^(?=.*[A-Z])(?=.*[²&~\"#'\{\(\[\-|`_\\^@\)\°=+\}¨^\$£¤%?,;.:\/!§*<>])(?=.*[0-9].*)(?=.*[a-z]).{8,}$/";
break;
case CrudType::text():
case CrudType::string():
default:
$defaultRules[] = 'string';
break;
}
return [$key => array_merge($defaultRules, $item['validate'] ?? self::$schemaCrud['validate'])];
})->all();
}
/**
* Get the model Name
*
* @return string
*/
public function getModelName(): string
{
return class_basename(get_class($this));
}
/**
* Get the Editable Properties
*
* @return array
*/
public static function getEditableProperties(): array
{
static::assertEditablePropertiesIsCorrect();
return static::$editableProperties;
}
/**
* Assert self::$editableProperties is in a correct format
*
* 'string' => CrudType::value()
*
* @return void
* @throws Exception
*/
private static function assertEditablePropertiesIsCorrect(): void
{
if (empty(static::$editableProperties)) {
throw new CrudException('$editableProperties must be defined in ' . __CLASS__);
}
foreach (static::$editableProperties as $name => &$prop) {
if (!is_string($name)) {
throw new CrudException(sprintf(
'editableProperties array value must be an array given: %s',
print_r($prop, true) // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Found
));
}
if (!is_array($prop)) {
throw new CrudException(sprintf(
'editableProperties array value must be an array given: %s',
print_r($prop, true) // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Found
));
}
$prop = array_merge_recursive_distinct(self::$schemaCrud, $prop);
// Auto label
$prop['label'] = $prop['label'] ?? ucfirst($name);
self::assertLabelIscorrect($prop);
self::assertPlaceholderIscorrect($prop);
self::assertTypeIscorrect($prop);
switch ($prop['type']) {
// Email auto rule
case CrudType::email():
$prop['validate'] = is_array($prop['validate']) ? $prop['validate'] : [];
if (!array_search('email:rfc,dns', $prop['validate'])) {
$prop['validate'][] = 'email:rfc,dns';
}
break;
case CrudType::json():
$prop['validate'] = is_array($prop['validate']) ? $prop['validate'] : [];
if (!array_search('json', $prop['validate'])) {
$prop['validate'][] = 'json';
}
break;
}
self::assertNullableIscorrect($prop);
self::assertReadonlyIscorrect($prop);
self::assertDisabledIscorrect($prop);
self::assertValidateIscorrect($prop);
self::assertRulesArecorrect($prop);
self::assertActionsArecorrect($prop);
self::assertBelongsToIsCorrect($prop);
self::assertEnumIscorrect($prop);
self::assertGetAttributeIscorrect($prop);
self::assertSetAttributeIscorrect($prop);
self::assertBelongsToManyIsCorrect($prop);
}
}
private static function assertLabelIscorrect(array $prop): void
{
if (!isset($prop['label']) or !is_string($prop['label'])) {
throw new CrudException('$editableProperties[\'label\'] array value must be a string');
}
}
private static function assertPlaceholderIscorrect(array $prop): void
{
if (!isset($prop['placeholder']) or !is_string($prop['placeholder'])) {
throw new CrudException('$editableProperties[\'placeholder\'] array value must be a string');
}
}
private static function assertTypeIscorrect(array $prop): void
{
if (!isset($prop['type']) or !is_object($prop['type']) or get_class($prop['type']) !== CrudType::class) {
throw new CrudException('$editableProperties[\'type\'] value must be a CrudType enum');
}
}
private static function assertNullableIscorrect(array $prop): void
{
if (!isset($prop['nullable']) or !is_bool($prop['nullable'])) {
throw new CrudException('$editableProperties[\'nullable\'] array value must be a boolean');
}
}
private static function assertReadonlyIscorrect(array $prop): void
{
if (
!isset($prop['readonly']) or
(!is_bool($prop['readonly']) and !\is_callable($prop['readonly']))
) {
throw new CrudException('$editableProperties[\'readonly\'] array value must be a boolean or a function returnning a boolean');
}
}
private static function assertDisabledIscorrect(array $prop): void
{
if (!isset($prop['disabled']) or !is_bool($prop['disabled'])) {
throw new CrudException('$editableProperties[\'disabled\'] array value must be a boolean');
}
}
private static function assertRequiredIscorrect(array $prop): void
{
if (!isset($prop['required']) or !is_bool($prop['required'])) {
throw new CrudException('$editableProperties[\'required\'] array value must be a boolean');
}
}
private static function assertValidateIscorrect(array $prop): void
{
if (!isset($prop['validate']) or !\is_array($prop['validate'])) {
throw new CrudException('$editableProperties[\'validate\'] array value must be an array');
}
}
private static function assertGetAttributeIscorrect(array $prop): void
{
if (!is_null($prop['getAttribute']) and !is_callable($prop['getAttribute'])) {
throw new CrudException('$editableProperties[\'getAttribute\'] array value must be callable');
}
}
private static function assertSetAttributeIscorrect(array $prop): void
{
if (!is_null($prop['setAttribute']) and !is_callable($prop['setAttribute'])) {
throw new CrudException('$editableProperties[\'setAttribute\'] array value must be callable');
}
}
private static function assertRulesArecorrect(array $prop): void
{
$allowedRules = [
'min' => function ($v) {
return \is_numeric($v);
},
'max' => function ($v) {
return \is_numeric($v);
},
'step' => function ($v) {
return \is_numeric($v);
},
];
if (!isset($prop['rules']) or !\is_array($prop['rules'])) {
throw new CrudException('$editableProperties[\'rules\'] array value must be an array');
}
if (count(array_diff_key($prop['rules'], $allowedRules))) {
throw new CrudException(sprintf(
'$editableProperties[\'rules\'] array contains unallowed rule, allowed are : %s',
implode(',', array_keys($allowedRules))
));
}
foreach ($allowedRules as $k => $f) {
if (array_key_exists($k, $prop['rules']) and !$f($prop['rules'][$k])) {
throw new CrudException(sprintf(
'$editableProperties[\'rules\'] rule `%s` does not validate with value `%s`',
$k,
$prop['rules'][$k]
));
}
}
}
private static function assertActionsArecorrect(array $prop): void
{
if (!isset($prop['actions']) or !\is_array($prop['actions'])) {
throw new CrudException('$editableProperties[\'actions\'] array value must be an array');
}
foreach ($prop['actions'] as $action) {
if (!is_object($action) or get_class($action) !== CrudAction::class) {
throw new CrudException(sprintf(
'$editableProperties[\'actions\'] array value must contain `%s` objects',
CrudAction::class
));
}
}
}
private static function assertEnumIscorrect(array $prop): void
{
if (
$prop['type']->equals(CrudType::enum()) and (is_null($prop['enum']) or
!\is_subclass_of($prop['enum'], BaseEnum::class))
) {
throw new CrudException(sprintf(
'$editableProperties[\'enum\'] array value must be an enum `%s`',
BaseEnum::class
));
}
}
private static function assertBelongsToIsCorrect(array $prop): void
{
if (is_null($prop['belongsTo'])) {
return;
}
if (
$prop['type']->equals(CrudType::belongsTo()) and
(!\is_string($prop['belongsTo']) or
!\class_exists($prop['belongsTo']) or
is_a($prop['belongsTo'], Model::class, true) or
!in_array(IsCrudModel::class, class_uses_recursive($prop['belongsTo'])))
) {
throw new CrudException(sprintf(
'$editableProperties[\'belongsTo\'] array value must be a CrudModel given: `%s`',
$prop['belongsTo']
));
}
}
private static function assertBelongsToManyIsCorrect(array $prop): void
{
if (is_null($prop['belongsToMany'])) {
return;
}
if (
$prop['type']->equals(CrudType::belongsToMany()) and
(!\is_string($prop['belongsToMany']) or
!\class_exists($prop['belongsToMany']) or
is_a($prop['belongsToMany'], Model::class, true) or
!in_array(IsCrudModel::class, class_uses_recursive($prop['belongsToMany'])))
) {
throw new CrudException(sprintf(
'$editableProperties[\'belongsToMany\'] array value must be a CrudModel given: `%s`',
$prop['belongsToMany']
));
}
if (
$prop['type']->equals(CrudType::belongsToMany()) and
(!\is_string($prop['belongsToManyLabel']))
) {
throw new CrudException(sprintf(
'$editableProperties[\'belongsToManyLabel\'] must be string using belongsToMany, given: `%s`',
$prop['belongsToMany']
));
}
}
/**
* Get eloquent relationships
*
* @url https://laracasts.com/discuss/channels/eloquent/get-all-model-relationships
*
* @return array
*/
private static function getRelationships()
{
$instance = new static();
// Get public methods declared without parameters and non inherited
$class = get_class($instance);
$allMethods = (new \ReflectionClass($class))->getMethods(\ReflectionMethod::IS_PUBLIC);
$methods = array_filter(
$allMethods,
function ($method) use ($class) {
return $method->class === $class
&& !$method->getParameters() // relationships have no parameters
&& $method->getName() !== 'getRelationships' // prevent infinite recursion
&& $method->getName() !== 'gotHasManyRelationWithRestrictOnDelete'; // prevent infinite recursion
}
);
DB::beginTransaction();
$relations = [];
foreach ($methods as $method) {
try {
$methodName = $method->getName();
$methodReturn = $instance->$methodName();
if (!$methodReturn instanceof Relation) {
continue;
}
} catch (\Throwable $th) {
continue;
}
$type = (new \ReflectionClass($methodReturn))->getShortName();
$model = get_class($methodReturn->getRelated());
$relations[$methodName] = [$type, $model];
}
DB::rollBack();
return $relations;
}
}

View file

@ -9,6 +9,7 @@
"license": "MIT",
"require": {
"php": "^7.3|^8.0",
"doctrine/dbal": "^3.1",
"fideloper/proxy": "^4.4",
"fruitcake/laravel-cors": "^2.0",
"guzzlehttp/guzzle": "^7.0.1",

View file

@ -174,6 +174,7 @@ return [
// App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\RouteServiceProvider::class,
App\Providers\TranslationServiceProvider::class
],
@ -227,7 +228,11 @@ return [
'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class,
'TicketType' => App\Enums\TicketType::class,
'TicketStatus' => App\Enums\TicketStatus::class
'TicketStatus' => App\Enums\TicketStatus::class,
'Utils' => App\Lib\Utils::class,
'CrudAction' => App\Enums\CrudAction::class,
'CrudType' => App\Enums\CrudType::class,
'Role' => App\Enums\Role::class,
],
];

View file

@ -91,6 +91,10 @@ return [
'name' => env('MAIL_FROM_NAME', 'Example'),
],
'contactsList' => [
'serviceweb' => env('MAIL_LIST_SERVICEWEB', 'informatique.internet@ldc.fr')
],
/*
|--------------------------------------------------------------------------
| Markdown Mail Settings

View file

@ -0,0 +1,33 @@
<?php
use App\Enums\TicketStatus;
use App\Models\Ticket;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class UpdateTicketsStatusWithEnumStatus extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
foreach (Ticket::where('status', 4)->get() as $ticket) {
$ticket->status = TicketStatus::archived();
$ticket->save();
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
//
}
}

View file

@ -0,0 +1,41 @@
<?php
use App\Enums\TicketStatus;
use App\Models\Ticket;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class UpdateTicketsStatusWithEnumStatus2 extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
foreach (Ticket::where('status', 5)->get() as $ticket) {
$ticket->status = TicketStatus::production();
$ticket->save();
}
foreach (Ticket::where('status', 4)->get() as $ticket) {
$ticket->status = TicketStatus::recette();
$ticket->save();
}
foreach (Ticket::where('status', 3)->get() as $ticket) {
$ticket->status = TicketStatus::processed();
$ticket->save();
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
//
}
}

View file

@ -0,0 +1,54 @@
<?php
use App\Enums\TicketStatus;
use App\Models\Ticket;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class UpdateTicketsStatusWithEnumStatus3 extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
//save saveQuietly (without events)
foreach (Ticket::where('status', 9)->get() as $ticket) {
$ticket->status = TicketStatus::archived();
$ticket->saveQuietly();
}
foreach (Ticket::where('status', 6)->get() as $ticket) {
$ticket->status = TicketStatus::production();
$ticket->saveQuietly();
}
foreach (Ticket::where('status', 5)->get() as $ticket) {
$ticket->status = TicketStatus::recette();
$ticket->saveQuietly();
}
foreach (Ticket::where('status', 4)->get() as $ticket) {
$ticket->status = TicketStatus::processed();
$ticket->saveQuietly();
}
foreach (Ticket::where('status', 3)->get() as $ticket) {
$ticket->status = TicketStatus::waiting();
$ticket->saveQuietly();
}
foreach (Ticket::where('status', 2)->get() as $ticket) {
$ticket->status = TicketStatus::inprogress();
$ticket->saveQuietly();
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
//
}
}

View file

@ -0,0 +1,64 @@
<?php
use App\Models\User;
use App\Models\ProjectUser;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateProjectUserTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('project_user', function (Blueprint $table) {
$table->foreignId('project_id')->constrained('projects');
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->unique(['project_id', 'user_id']);
});
/** @var \App\Models\User */
foreach (User::all() as $user) {
if ($user->project_id) {
$uP = new ProjectUser();
$uP->project_id = $user->project_id;
$uP->user_id = $user->id;
$uP->saveOrFail();
}
}
Schema::table('users', function (Blueprint $table) {
$table->dropForeign(['project_id']);
$table->dropColumn('project_id');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->foreignId('project_id')->nullable()
->after('role')->constrained('projects');
});
/** @var \App\Models\ProjectUser */
foreach (ProjectUser::all() as $uP) {
try {
/** @var \App\Models\User */
$user = User::whereId($uP->user_id)->firstOrFail();
$user->project_id = $uP->project_id;
$user->saveOrFail();
//phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
} catch (ModelNotFoundException $e) {
// ignore
}
}
Schema::dropIfExists('project_user');
}
}

View file

@ -11,16 +11,17 @@
},
"devDependencies": {
"axios": "^0.21.1",
"bootstrap": "^4.0.0",
"cross-env": "^7.0",
"fslightbox": "^3.2.3",
"jquery": "^3.2",
"laravel-mix": "^6.0.16",
"laravel-mix-polyfill": "^3.0.1",
"lodash": "^4.17.19",
"popper.js": "^1.12",
"resolve-url-loader": "^3.1.0",
"sass": "^1.15.2",
"sass-loader": "^8.0.0",
"vue": "^2.6.14",
"vue-loader": "^15.9.7",
"vue-select": "^3.11.2",
"vue-template-compiler": "^2.6.12"
}
}

4
public/css/app.css vendored Normal file

File diff suppressed because one or more lines are too long

1
public/css/app.css.map Normal file

File diff suppressed because one or more lines are too long

3
public/js/app.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,14 @@
/*!
* Vue.js v2.6.14
* (c) 2014-2021 Evan You
* Released under the MIT License.
*/
/**
* @license
* Lodash <https://lodash.com/>
* Copyright OpenJS Foundation and other contributors <https://openjsf.org/>
* Released under MIT license <https://lodash.com/license>
* Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>
* Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
*/

1
public/js/app.js.map Normal file

File diff suppressed because one or more lines are too long

1
resources/js/app.js vendored
View file

@ -1 +1,2 @@
require('./bootstrap');
require('./crud');

View file

@ -6,14 +6,6 @@ window._ = require('lodash');
* code may be modified to fit the specific needs of your application.
*/
try {
window.Popper = require('popper.js').default;
window.$ = window.jQuery = require('jquery');
require('bootstrap');
require('fslightbox');
} catch (e) {}
/**
* We'll load the axios HTTP library which allows us to easily issue requests
* to our Laravel back-end. This library automatically handles sending the
@ -40,3 +32,36 @@ window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
// cluster: process.env.MIX_PUSHER_APP_CLUSTER,
// forceTLS: true
// });
window.Vue = require('vue').default;
import vSelect from "vue-select"
Vue.component('v-select', vSelect)
let vueOptions = {
el: "#app"
};
// register modules
const modules = require.context("./modules", true, /\.js$/i);
modules.keys().map(key => Vue.mixin(modules(key).default));
// Register components
const vues = require.context("./components", true, /\.vue$/i);
vues.keys().map(key =>
Vue.component(
key
.split("/")
.pop()
.split(".")[0]
.replace(/\.?([A-Z])/g, function (x, y) {
return "-" + y.toLowerCase();
})
.replace(/^-/, "")
.replace("-component", ""),
vues(key).default
)
);
const app = new Vue(vueOptions);
require('fslightbox');

View file

@ -0,0 +1,112 @@
<template>
<div>
<v-select
:id="name"
ref="select"
:loading="loading"
v-model="value"
:clearable="false"
label="label"
:options='data'
@search="search"
:filterable="true"
:disabled="dReadonly||dDisabled"
:searchable="true">
<li slot="list-footer" class="pagination">
<button
@click="$event.preventDefault(); page -= 1"
:disabled="!hasPrevPage"
class="btn btn-sm btn-primary mt-2 mb-2 ms-2 me-2">{{ __('Précédent') }}</button>
<button
@click="$event.preventDefault(); page += 1"
:disabled="!hasNextPage"
class="btn btn-sm btn-primary mt-2 mb-2">{{ __('Suivant') }}</button>
</li>
</v-select>
<input type="hidden" v-model="value.value" :name="name" />
</div>
</template>
<script>
export default {
mounted() {
this.dDisabled = !!this.disabled
this.dReadonly = !!this.readonly
this.value = JSON.parse(this.dbValue)
if(!this.value) {
this.value = {
label: null,
value: null
}
}
this.search(false)
},
props: [
'disabled',
'readonly',
'name',
'dbValue'
],
data() {
return {
dDisabled : false,
dReadonly : false,
value: {
label: null,
value: null
},
loading: false,
searchText: '',
data: [],
page: 1,
perPage: 15,
hasNextPage: true,
hasPrevPage: false
}
},
watch: {
page() {
if(this.page < 1) {
this.page = 1
}
if(this.page > 1) {
this.hasPrevPage = true
} else {
this.hasPrevPage = false
}
this.search()
},
data() {
if(this.data.length == this.perPage) {
this.hasNextPage = true
} else {
this.hasNextPage = false
}
}
},
methods: {
search (search = null) {
if(search !== false && search !== null) {
this.searchText = search
}
let init = search === false ? true : false
let route = this.route('model.index')
route += '?page='+this.page
route += '&s='+this.searchText
this.loading = true
axios.get(route)
.then(response => {
this.data = response.data
if (init && this.data.length && this.value.value == null) {
this.value = this.data[0]
}
})
.catch(error => {
console.log(error);
}).then(() => {
this.loading = false
})
},
}
}
</script>

View file

@ -0,0 +1,108 @@
<template>
<div>
<v-select
:id="name"
ref="select"
:loading="loading"
v-model="values"
:clearable="true"
label="label"
:options='data'
@search="search"
multiple
:disabled="dReadonly||dDisabled"
:filterable="true"
:searchable="true">
<li slot="list-footer" class="pagination">
<button
@click="$event.preventDefault(); page -= 1"
:disabled="!hasPrevPage"
class="btn btn-sm btn-primary mt-2 mb-2 ms-2 me-2">{{ __('Précédent') }}</button>
<button
@click="$event.preventDefault(); page += 1"
:disabled="!hasNextPage"
class="btn btn-sm btn-primary mt-2 mb-2">{{ __('Suivant') }}</button>
</li>
</v-select>
<input
type="hidden"
:name="name+'[]'"
v-for="item in values" :key="item.value"
:value="item.value"/>
</div>
</template>
<script>
export default {
mounted() {
this.dDisabled = !!this.disabled
this.dReadonly = !!this.readonly
let values = JSON.parse(this.dbValue)
for(let k in values) {
this.values.push(values[k])
}
this.search(false)
},
props: [
'disabled',
'readonly',
'name',
'dbValue'
],
data() {
return {
dDisabled : false,
dReadonly : false,
values: [],
loading: false,
searchText: '',
data: [],
page: 1,
perPage: 15,
hasNextPage: true,
hasPrevPage: false
}
},
watch: {
page() {
if(this.page < 1) {
this.page = 1
}
if(this.page > 1) {
this.hasPrevPage = true
} else {
this.hasPrevPage = false
}
this.search()
},
data() {
if(this.data.length == this.perPage) {
this.hasNextPage = true
} else {
this.hasNextPage = false
}
}
},
methods: {
search (search = null) {
if(search !== false && search !== null) {
this.searchText = search
}
let init = search === false ? true : false
let route = this.route('model.index')
route += '?page='+this.page
route += '&s='+this.searchText
this.loading = true
axios.get(route)
.then(response => {
this.data = response.data
})
.catch(error => {
console.log(error);
}).then(() => {
this.loading = false
})
},
}
}
</script>

19
resources/js/crud.js vendored Normal file
View file

@ -0,0 +1,19 @@
window.__CRUD = {
confirmDelete: (e) => {
let self = e.target
e.preventDefault();
Swal.fire({
title: 'Confirmez',
text: 'Voulez-vous vraiment supprimer cet élément ?',
icon: 'question',
confirmButtonText: 'Supprimer',
showCancelButton: true,
cancelButtonText: 'Annuler',
confirmButtonColor: '#dc3545'
}).then((result) => {
if (result.isConfirmed) {
self.submit()
}
})
}
}

8
resources/js/modules/asset.js vendored Normal file
View file

@ -0,0 +1,8 @@
export default {
methods: {
asset(path) {
return window._asset + path;
}
},
}

8
resources/js/modules/route.js vendored Normal file
View file

@ -0,0 +1,8 @@
export default {
methods: {
route(routeName) {
return Object.keys(window._routes).indexOf(routeName) !== -1 ? window._routes[routeName] : null;
}
},
}

41
resources/js/modules/trans.js vendored Normal file
View file

@ -0,0 +1,41 @@
export default {
data() {
return {
locale: window._locale
};
},
methods: {
/**
* Translate the given key.
*/
__(key, replace) {
let translation, translationNotFound = true, vars;
try {
translation = key.split('.').reduce((t, i) => t[i] || null, window._translations.php)
if (translation) {
translationNotFound = false;
}
} catch (e) {
translation = key;
}
if (translationNotFound) {
translation = window._translations['json'][key] ? window._translations['json'][key] : key;
}
vars = translation.match(/:[0-9A-Za-z_]*/g);
if (vars) {
for (let match of vars) {
try {
translation = translation.replace(new RegExp(match, 'g'), replace[match.substr(1)]);
} catch (e) {
console.error('String replace failed ', e);
}
}
}
return translation
}
},
}

12
resources/sass/_crud.scss vendored Normal file
View file

@ -0,0 +1,12 @@
@import "vue-select/src/scss/vue-select.scss";
// readonly checkboxes
select[readonly],input[type='checkbox'][readonly]{
pointer-events: none;
}
//bootstrap overload
.btn-group > form:not(:first-child) > .btn-group:not(:last-child) > .btn, .btn-group > form:not(:first-child) > .btn:last-child:not(.dropdown-toggle) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}

View file

@ -1,12 +1,12 @@
@use "sass:color";
// Fonts
@import url('https://fonts.googleapis.com/css?family=Nunito');
@import 'crud';
// Variables
@import 'variables';
// Bootstrap
@import '~bootstrap/scss/bootstrap';
a.col_sort {
&:link,:visited,:hover,:active {
text-decoration: none;
@ -16,3 +16,27 @@ a.col_sort {
.container-xxl {
max-width: 1340px !important;
}
.alert-success .text-muted {
//color : #176319 !important;
color : color.adjust(#28b62c, $lightness: -20%) !important;
}
.alert-primary {
.text-muted {
//color : #0b4a63 !important;
color : color.adjust(#158cba, $lightness: -20%) !important;
}
.text-primary {
color : color.adjust(#158cba, $lightness: -20%) !important;
}
}
.alert-secondary .text-muted {
color : color.adjust(#f0f0f0, $lightness: -20%) !important;
}
.alert-danger .text-muted {
color : color.adjust(#ff4136, $lightness: -20%) !important;
}

View file

@ -0,0 +1,53 @@
@extends('layout.app')
@section('title', sprintf('%s - Ajouter', Str::plural(class_basename($modelClass))))
@section('description', sprintf('Ajouter un %s', class_basename($modelClass)))
@section('metaIndex', 'noindex,nofollow')
@section('content')
<div class="container container-xxl">
<div class="row justify-content-center">
<div class="col-12">
<div class="card">
@include('modules.flashMessage')
<div class="card-header">
@can('viewAny', $modelClass)
<a href="{{route("$modelTable.index")}}">{{ trans(Str::plural(class_basename($modelClass))) }}</a>&nbsp;-&nbsp;{{ __('Ajouter') }}
@else
{{ Str::plural(class_basename($modelClass)) }}&nbsp;-&nbsp;{{ __('Ajouter') }}
@endcan
</div>
<div class="card-body">
<form action="{{ route("$modelTable.store") }}" method="POST">
@csrf
<table class="table table-sm table-bordered border-primary align-middle table-striped text-center">
<thead class="thead-dark">
<tr>
<th scope="col" class="w-25">{{ __('Propriété') }}</th>
<th scope="col" class="w-75">{{ __('Valeur') }}</th>
</tr>
</thead>
<tbody>
@foreach ($modelClass::getEditableProperties() as $fieldName => $prop)
@php
// create form cannot accept readonly field
$prop['readonly'] = false;
@endphp
@if(in_array(CrudAction::create(), $prop['actions']))
<tr>
@include('crud.formfield')
</tr>
@endif
@endforeach
</tbody>
</table>
<div class="col text-center">
<button class="btn btn-info" type="submit">{{ __('Créer') }}</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection

View file

@ -0,0 +1,62 @@
@extends('layout.app')
@section('title', sprintf('%s - Editer', trans(Str::plural(class_basename($modelClass)))))
@section('description', sprintf('Editer un %s', trans(class_basename($modelClass))))
@section('metaIndex', 'noindex,nofollow')
@section('content')
<div class="container container-xxl">
<div class="row justify-content-center">
<div class="col-12">
<div class="card">
@include('modules.flashMessage')
<div class="card-header">
@can('viewAny', $modelClass)
<a href="{{route("$modelTable.index")}}">{{ trans(Str::plural(class_basename($modelClass))) }}</a>&nbsp;-&nbsp;{{ __('Editer') }}
@else
{{ trans(Str::plural(class_basename($modelClass))) }}&nbsp;-&nbsp;{{ __('Editer') }}
@endcan
<div class="btn-group float-end" role="group" aria-label="{{ __('Actions') }}">
@can('view', $model)
<a href="{{route("$modelTable.show", $model)}}" class="btn btn-primary ">{{ __('Voir') }}</a>
@endcan
@can('delete', $model)
<form action="{{route("$modelTable.destroy", $model)}}" method="POST" onsubmit="__CRUD.confirmDelete(event)">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger">{{ __('Supprimer') }}</button>
</form>
@endcan
</div>
</div>
<div class="card-body">
<form action="{{ route("$modelTable.update", $model) }}" method="POST">
@method('put')
@csrf
<table class="table table-sm table-bordered border-primary align-middle table-striped text-center">
<thead class="thead-dark">
<tr>
<th scope="col" class="w-25">{{ __('Propriété') }}</th>
<th scope="col" class="w-75">{{ __('Valeur') }}</th>
</tr>
</thead>
<tbody>
@foreach ($modelClass::getEditableProperties() as $fieldName => $prop)
@if(in_array(CrudAction::update(), $prop['actions']))
<tr>
@include('crud.formfield')
</tr>
@endif
@endforeach
</tbody>
</table>
<div class="col text-center">
<button class="btn btn-info" type="submit">{{ __('Modifier') }}</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection

View file

@ -0,0 +1,38 @@
@php
$readonly = is_callable($prop['readonly']) ? $prop['readonly']() : $prop['readonly'];
@endphp
@switch($prop['type'])
@case(CrudType::password())
@include('crud.formfields.password')
@break
@case(CrudType::boolean())
@include('crud.formfields.boolean')
@break
@case(CrudType::int())
@include('crud.formfields.int')
@break
@case(CrudType::float())
@include('crud.formfields.float')
@break
@case(CrudType::email())
@include('crud.formfields.email')
@break
@case(CrudType::text())
@include('crud.formfields.text')
@break
@case(CrudType::json())
@include('crud.formfields.json')
@break
@case(CrudType::enum())
@include('crud.formfields.enum')
@break
@case(CrudType::belongsTo())
@include('crud.formfields.belongsTo')
@break
@case(CrudType::belongsToMany())
@include('crud.formfields.belongsToMany')
@break
@case(CrudType::string())
@default
@include('crud.formfields.string')
@endswitch

View file

@ -0,0 +1,30 @@
@php
$related = isset($model) ? $props[$fieldName]['belongsTo']::find($model->{$fieldName}) : null;
if($related) {
$related = [
'label' => $related->crudLabel,
'value' => $related->crudValue
];
}
@endphp
<td>
<label for="{{ $fieldName }}" class="form-label">{{ $prop['label'] }}</label>
</td>
<td>
<belongs-to
readonly="{{ $readonly }}"
name="{{ $fieldName }}"
db-value='@json($related)'>
</belongs-to>
</td>
@push('scriptsConstants')
<script>
window._locale = '{{ app()->getLocale() }}';
window._translations = @json(cache(sprintf('translations.%s', app()->getLocale())));
window._asset = '{{ asset('') }}';
window._routes = {
'model.index': '{{ route((new $props[$fieldName]['belongsTo'])->getTable().'.index') }}'
};
</script>
@endpush

View file

@ -0,0 +1,29 @@
@php
$related = isset($model) ? $model->{$fieldName}->mapWithKeys(function ($model) use($prop) {
return [$model->{'id'} => [
'label' => $model->{$prop['belongsToManyLabel']},
'value' => $model->{'id'}
]];
}) : [];
@endphp
<td>
<label for="{{ $fieldName }}" class="form-label">{{ $prop['label'] }}</label>
</td>
<td>
<belongs-to-many
readonly="{{ $readonly }}"
name="{{ $fieldName }}"
db-value='@json($related)'>
</belongs-to-many>
</td>
@push('scriptsConstants')
<script>
window._locale = '{{ app()->getLocale() }}';
window._translations = @json(cache(sprintf('translations.%s', app()->getLocale())));
window._asset = '{{ asset('') }}';
window._routes = {
'model.index': '{{ route((new $props[$fieldName]['belongsToMany'])->getTable().'.index') }}'
};
</script>
@endpush

View file

@ -0,0 +1,20 @@
<td>
<label for="{{ $fieldName }}" class="form-label">{{ $prop['label'] }}</label>
</td>
<td>
<label for="{{ $fieldName }}" class="row">
<div class="col">
<div class="form-check form-switch ms-2">
<input
id="{{ $fieldName }}"
name="{{ $fieldName }}"
type="checkbox"
class="ms-2 form-check-input"
value="1"
@if(old($fieldName) ?? ((isset($model) and $model->{$fieldName}) ? $model->{$fieldName} : false))checked @endif
@if($prop['disabled'])disabled @endif
@if($readonly)readonly @endif>
</div>
</div>
</label>
</td>

View file

@ -0,0 +1,33 @@
<td>
<label for="{{ $fieldName }}" class="form-label">{{ $prop['label'] }}</label>
</td>
<td>
@if(strlen($prop['placeholder']))
<div class="form-floating">
<input
id="{{ $fieldName }}"
name="{{ $fieldName }}"
type="email"
class="form-control"
value="{{ old($fieldName) ?? $model->{$fieldName} ?? '' }}"
@if(isset($prop['rules']['max']))maxlength="{{ $prop['rules']['max'] }}"@endif
@if(isset($prop['rules']['min']))minlength="{{ $prop['rules']['min'] }}"@endif
@if($prop['disabled'])disabled @endif
@if($readonly)readonly @endif
@if($prop['required'] or (!$prop['required'] and !$prop['nullable']))required @endif>
<label for="{{ $fieldName }}" class="form-label">{{ __('ex: :placeholder', ['placeholder' => $prop['placeholder']]) }}</label>
</div>
@else
<input
id="{{ $fieldName }}"
name="{{ $fieldName }}"
type="email"
class="form-control"
value="{{ old($fieldName) ?? $model->{$fieldName} ?? '' }}"
@if(isset($prop['rules']['max']))maxlength="{{ $prop['rules']['max'] }}"@endif
@if(isset($prop['rules']['min']))minlength="{{ $prop['rules']['min'] }}"@endif
@if($prop['disabled'])disabled @endif
@if($readonly)readonly @endif
@if($prop['required'] or (!$prop['required'] and !$prop['nullable']))required @endif>
@endif
</td>

View file

@ -0,0 +1,13 @@
<td>
<label for="{{ $fieldName }}" class="form-label">{{ $prop['label'] }}</label>
</td>
<td>
<select id="{{ $fieldName }}" class="form-control" name="{{ $fieldName }}" @if($prop['readonly']) readonly @endif @if($prop['disabled']) disabled @endif>
{{-- TODO handle enum nullable ? --}}
{{-- @if($prop['nullable'])
@endif --}}
@foreach ($prop['enum']::toArray() as $enum)
<option value="{{$enum->value}}" @if(isset($model) and $enum->equals($model->{$fieldName}))selected @endif>{{__($enum->label)}}</option>
@endforeach
</select>
</td>

View file

@ -0,0 +1,18 @@
<td>
<label for="{{ $fieldName }}" class="form-label">{{ $prop['label'] }}</label>
</td>
<td>
<input
id="{{ $fieldName }}"
class="form-control"
name="{{ $fieldName }}"
type="number"
placeholder="{{ $prop['rules']['min'] }}"
value="{{ old($fieldName) ?? $model->{$fieldName} ?? $prop['rules']['min'] }}"
@if(isset($prop['rules']['max']))max="{{ $prop['rules']['max'] }}"@endif
@if(isset($prop['rules']['min']))min="{{ $prop['rules']['min'] }}"@endif
@if(isset($prop['rules']['step']))step="{{ $prop['rules']['step'] }}"@endif
@if($prop['disabled'])disabled @endif
@if($readonly)readonly @endif
@if($prop['required'] or (!$prop['required'] and !$prop['nullable']))required @endif>
</td>

View file

@ -0,0 +1,18 @@
<td>
<label for="{{ $fieldName }}" class="form-label">{{ $prop['label'] }}</label>
</td>
<td>
<input
id="{{ $fieldName }}"
class="form-control"
name="{{ $fieldName }}"
type="number"
placeholder="{{ $prop['rules']['min'] }}"
value="{{ old($fieldName) ?? $model->{$fieldName} ?? $prop['rules']['min'] }}"
@if(isset($prop['rules']['max']))max="{{ $prop['rules']['max'] }}"@endif
@if(isset($prop['rules']['min']))min="{{ $prop['rules']['min'] }}"@endif
@if(isset($prop['rules']['step']))step="{{ $prop['rules']['step'] }}"@endif
@if($prop['disabled'])disabled @endif
@if($readonly)readonly @endif
@if($prop['required'] or (!$prop['required'] and !$prop['nullable']))required @endif>
</td>

View file

@ -0,0 +1,22 @@
<td>
<label for="{{ $fieldName }}" class="form-label">{{ $prop['label'] }}</label>
</td>
<td>
<div class="mb-3">
<textarea id="{{ $fieldName }}" name="{{ $fieldName }}" rows="3" class="form-control" @if($readonly) readonly @endif>{{
old($fieldName) ?? ($model->{$fieldName} ? (is_array($model->{$fieldName}) ? json_encode($model->{$fieldName}) : $model->{$fieldName}) : '')
}}</textarea>
</div>
</td>
@push('scripts')
<script src="https://cdn.ckeditor.com/ckeditor5/27.1.0/classic/ckeditor.js"></script>
<script>
ClassicEditor
.create(
document.querySelector( '#{{ $fieldName }}' )
)
.catch( error => {
console.error( error );
} );
</script>
@endpush

View file

@ -0,0 +1,67 @@
<td>
<label for="{{ $fieldName }}" class="form-label">{{ $prop['label'] }}</label>
</td>
<td>
@if(strlen($prop['placeholder']))
<div class="form-floating">
<input
id="{{ $fieldName }}"
name="{{ $fieldName }}"
type="password"
class="form-control"
placeholder="{{ $prop['placeholder'] }}"
@if(isset($prop['rules']['max']))maxlength="{{ $prop['rules']['max'] }}"@endif
@if(isset($prop['rules']['min']))minlength="{{ $prop['rules']['min'] }}"@endif
@if($prop['disabled'])disabled @endif
@if($readonly)readonly @endif
@if($prop['required'] or !$prop['nullable'])required @endif>
<label for="{{ $fieldName }}" class="form-label">{{ __('ex: :placeholder', ['placeholder' => $prop['placeholder']]) }}</label>
</div>
@else
<input
id="{{ $fieldName }}"
name="{{ $fieldName }}"
type="password"
class="form-control"
placeholder="{{ $prop['placeholder'] }}"
@if(isset($prop['rules']['max']))maxlength="{{ $prop['rules']['max'] }}"@endif
@if(isset($prop['rules']['min']))minlength="{{ $prop['rules']['min'] }}"@endif
@if($prop['disabled'])disabled @endif
@if($readonly)readonly @endif
@if($prop['required'] or !$prop['nullable'])required @endif>
@endif
</td>
</tr><tr>
<td>
<label for="{{ $fieldName }}-confirm" class="form-label">{{ __('Confirmation')}}&nbsp;{{ $prop['label'] }}</label>
</td>
<td>
@if(strlen($prop['placeholder']))
<div class="form-floating">
<input
id="{{ $fieldName }}-confirm"
name="{{ $fieldName }}-confirm"
type="password"
class="form-control"
placeholder="{{ $prop['placeholder'] }}"
@if(isset($prop['rules']['max']))maxlength="{{ $prop['rules']['max'] }}"@endif
@if(isset($prop['rules']['min']))minlength="{{ $prop['rules']['min'] }}"@endif
@if($prop['disabled'])disabled @endif
@if($readonly)readonly @endif
@if($prop['required'] or !$prop['nullable'])required @endif>
<label for="{{ $fieldName }}-confirm" class="form-label">{{ __('ex: :placeholder', ['placeholder' => $prop['placeholder']]) }}</label>
</div>
@else
<input
id="{{ $fieldName }}-confirm"
name="{{ $fieldName }}-confirm"
type="password"
class="form-control"
placeholder="{{ $prop['placeholder'] }}"
@if(isset($prop['rules']['max']))maxlength="{{ $prop['rules']['max'] }}"@endif
@if(isset($prop['rules']['min']))minlength="{{ $prop['rules']['min'] }}"@endif
@if($prop['disabled'])disabled @endif
@if($readonly)readonly @endif
@if($prop['required'] or !$prop['nullable'])required @endif>
@endif
</td>

View file

@ -0,0 +1,33 @@
<td>
<label for="{{ $fieldName }}" class="form-label">{{ $prop['label'] }}</label>
</td>
<td>
@if(strlen($prop['placeholder']))
<div class="form-floating">
<input
id="{{ $fieldName }}"
name="{{ $fieldName }}"
type="text"
class="form-control"
value="{{ old($fieldName) ?? $model->{$fieldName} ?? '' }}"
@if(isset($prop['rules']['max']))maxlength="{{ $prop['rules']['max'] }}"@endif
@if(isset($prop['rules']['min']))minlength="{{ $prop['rules']['min'] }}"@endif
@if($prop['disabled'])disabled @endif
@if($readonly)readonly @endif
@if($prop['required'] or (!$prop['required'] and !$prop['nullable']))required @endif>
<label for="{{ $fieldName }}" class="form-label">{{ __('ex: :placeholder', ['placeholder' => $prop['placeholder']]) }}</label>
</div>
@else
<input
id="{{ $fieldName }}"
name="{{ $fieldName }}"
type="text"
class="form-control"
value="{{ old($fieldName) ?? $model->{$fieldName} ?? '' }}"
@if(isset($prop['rules']['max']))maxlength="{{ $prop['rules']['max'] }}"@endif
@if(isset($prop['rules']['min']))minlength="{{ $prop['rules']['min'] }}"@endif
@if($prop['disabled'])disabled @endif
@if($readonly)readonly @endif
@if($prop['required'] or (!$prop['required'] and !$prop['nullable']))required @endif>
@endif
</td>

View file

@ -0,0 +1,22 @@
<td>
<label for="{{ $fieldName }}" class="form-label">{{ $prop['label'] }}</label>
</td>
<td>
<div class="mb-3">
<textarea id="{{ $fieldName }}" name="{{ $fieldName }}" rows="3" class="form-control" @if($readonly) readonly @endif>{{
old($fieldName) ?? $model->{$fieldName} ?? ''
}}</textarea>
</div>
</td>
@push('scripts')
<script src="https://cdn.ckeditor.com/ckeditor5/27.1.0/classic/ckeditor.js"></script>
<script>
ClassicEditor
.create(
document.querySelector( '#{{ $fieldName }}' )
)
.catch( error => {
console.error( error );
} );
</script>
@endpush

View file

@ -0,0 +1,111 @@
@extends('layout.app')
@section('title', sprintf('%s - Liste', trans(Str::plural(class_basename($modelClass)))))
@section('description', sprintf('Liste des %s', trans(Str::plural(class_basename($modelClass)))))
@section('metaIndex', 'noindex,nofollow')
@section('content')
<div class="container container-xxl">
<div class="row justify-content-center">
<div class="col-12">
<div class="card">
@include('modules.flashMessage')
<div class="card-header">
<a href="{{route("$modelTable.index")}}">{{ trans(Str::plural(class_basename($modelClass))) }}</a>&nbsp;-&nbsp;{{ __('Liste') }}
@can('create', $modelClass)
<a href="{{route("$modelTable.create")}}" class="btn btn-primary float-end">{{ __('Ajouter') }}</a>
@endcan
</div>
<div class="card-body">
<div class="row justify-content-center">
<div class="col">
{{ $models->links() }}
</div>
<div class="col">
<form action="{{ url()->current() }}">
<div class="input-group mb-3">
<input type="search" name="search" class="form-control" value="{{ request()->search }}"/>
<input type="text" name="sort_col" class="d-none" value="{{ request()->sort_col }}"/>
<input type="text" name="sort_way" class="d-none" value="{{ request()->sort_way }}"/>
<a class="btn btn-danger" href="{{ url()->current() }}">Réinitialiser</a>
<button type="submit" class="btn btn-primary">
{{ __('Recherche') }}
</button>
</div>
</form>
</div>
</div>
<table class="table text-center">
<thead class="thead-dark">
<tr>
@foreach ($modelClass::getEditableProperties() as $fieldName => $prop)
@if(in_array(CrudAction::viewAny(), $prop['actions']))
@switch($prop['type'])
@case(CrudType::password())
{{-- Ignore --}}
@break
@default
<th scope="col">
{{ $prop['label'] }}
@include('crud.modules.sorter', ['colname' => $fieldName])
</th>
@endswitch
@endif
@endforeach
<th></th>
</tr>
</thead>
<tbody>
@if(count($models))
@foreach($models as $model)
<tr>
@foreach ($modelClass::getEditableProperties() as $fieldName => $prop)
@if(in_array(CrudAction::viewAny(), $prop['actions']))
@switch($prop['type'])
@case(CrudType::password())
{{-- Ignore --}}
@break
@default
<td>@include('crud.printfield', [
'action' => CrudAction::viewAny(),
'field' => $model->{$fieldName},
'props' => $prop
])</td>
@endswitch
@endif
@endforeach
<td>
<div class="btn-group" role="group" aria-label="{{ __('Actions') }}">
@can('view', $model)
<a href="{{route("$modelTable.show", $model)}}" class="btn btn-sm btn-primary">{{ __('Voir') }}</a>
@endcan
@can('update', $model)
<a href="{{route("$modelTable.edit", $model)}}" class="btn btn-sm btn-info">{{ __('Éditer') }}</a>
@endcan
@can('delete', $model)
<form action="{{route("$modelTable.destroy", $model)}}" method="POST" onsubmit="__CRUD.confirmDelete(event)">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-danger">{{ __('Supprimer') }}</button>
</form>
@endcan
</div>
</td>
</tr>
@endforeach
@else
<tr>
<td colspan="{{ count($modelClass::getEditableProperties()) }}">
Rien à afficher
</td>
</tr>
@endif
</tbody>
</table>
{{ $models->links() }}
</div>
</div>
</div>
</div>
</div>
@endsection

View file

@ -0,0 +1,12 @@
@php
$tableName = (new $modelClass())->getTable();
$sortParams = ['sort_col' => $colname];
$sortParams = request()->search ?
array_merge($sortParams, ['search' => request()->search]) : $sortParams;
@endphp
@if(!session("crud.$tableName.sort_way") or session("crud.$tableName.sort_col") !== $colname or (session("crud.$tableName.sort_way") === 'desc' and session("crud.$tableName.sort_col") === $colname))
<a class="col_sort text-secondary lead asc" href="{{ route("$tableName.index", array_merge($sortParams, ['sort_way' => 'asc'])) }}">&nbsp;&darr;&nbsp;</a>
@endif
@if(!session("crud.$tableName.sort_way") or session("crud.$tableName.sort_col") !== $colname or (session("crud.$tableName.sort_way") === 'asc' and session("crud.$tableName.sort_col") === $colname))
<a class="col_sort text-secondary lead desc" href="{{ route("$tableName.index", array_merge($sortParams, ['sort_way' => 'desc'])) }}">&nbsp;&uarr;&nbsp;</a>
@endif

View file

@ -0,0 +1,48 @@
@switch($action)
@case(CrudAction::viewAny())
@switch($props['type'])
@case(CrudType::email())
<a href="mailto:$field">{{ $field }}</a>
@break
@case(CrudType::enum())
{{ $model->{$fieldName}->label }}
@break
@case(CrudType::belongsTo())
{{ $prop['belongsTo']::find($model->{$fieldName})->{$model->crudLabelColumn} }}
@break
@case(CrudType::belongsToMany())
{{ $model->{$fieldName}->implode($prop['belongsToManyLabel'], ',') }}
@break
@case(CrudType::boolean())
@if($model->{$fieldName})OUI @else NON @endif
@break
@default
{{ $field }}
@endswitch
@break
@case(CrudAction::view())
@switch($props['type'])
@case(CrudType::email())
<a href="mailto:$field">{{ $field }}</a>
@break
@case(CrudType::enum())
{{ $model->{$fieldName}->label }}
@break
@case(CrudType::boolean())
@if($model->{$fieldName})OUI @else NON @endif
@break
@case(CrudType::belongsTo())
{{ $prop['belongsTo']::find($model->{$fieldName})->{$model->crudLabelColumn} }}
@break
@case(CrudType::belongsToMany())
{{ $model->{$fieldName}->implode($prop['belongsToManyLabel'], ',') }}
@break
@default
{{ $field }}
@endswitch
@break
@default
@dd('unknown action')
@endswitch

View file

@ -0,0 +1,66 @@
@extends('layout.app')
@section('title', sprintf('%s - Voir', trans(Str::plural(class_basename($modelClass)))))
@section('description', sprintf('Voir un %s', trans(class_basename($modelClass))))
@section('metaIndex', 'noindex,nofollow')
@section('content')
<div class="container container-xxl">
<div class="row justify-content-center">
<div class="col-12">
<div class="card">
@include('modules.flashMessage')
<div class="card-header">
@can('viewAny', $modelClass)
<a href="{{route("$modelTable.index")}}">{{ trans(Str::plural(class_basename($modelClass))) }}</a>&nbsp;-&nbsp;{{ __('Voir') }}
@else
{{ trans(Str::plural(class_basename($modelClass))) }}&nbsp;-&nbsp;{{ __('Voir') }}
@endcan
<div class="btn-group float-end" role="group" aria-label="{{ __('Actions') }}">
@can('update', $model)
<a href="{{route("$modelTable.edit", $model)}}" class="btn btn-info ">{{ __('Modifier') }}</a>
@endcan
@can('delete', $model)
<form action="{{route("$modelTable.destroy", $model)}}" method="POST" onsubmit="__CRUD.confirmDelete(event)">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger">{{ __('Supprimer') }}</button>
</form>
@endcan
</div>
</div>
<div class="card-body">
<table class="table table-sm table-bordered border-primary align-top table-striped text-center">
<thead class="thead-dark">
<tr>
<th scope="col" class="w-25">{{ __('Propriété') }}</th>
<th scope="col" class="w-75">{{ __('Valeur') }}</th>
</tr>
</thead>
<tbody>
@foreach ($modelClass::getEditableProperties() as $fieldName => $prop)
@if(in_array(CrudAction::view(), $prop['actions']))
@switch($prop['type'])
@case(CrudType::password())
{{-- Ignore --}}
@break
@default
<tr>
<td>{{ $prop['label'] }}</td>
<td>@include('crud.printfield', [
'action' => CrudAction::view(),
'field' => $model->{$fieldName},
'props' => $prop
])</td>
</tr>
@endswitch
@endif
@endforeach
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@endsection

View file

@ -0,0 +1,10 @@
{{ $appName }} Exception Handler
Une erreur est survenue sur le site {{ $appName }}
{{ $exception->getMessage() }}
date : {{ date('Y-m-d H:i:s') }}
code : {{$exception->getCode() }}
file : {{$exception->getFile() }}
line : {{$exception->getLine() }}
Veuillez vérifier les logs pour en voir la trace.

View file

@ -0,0 +1,16 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>{{ $appName }} Exception Handler</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
</head>
<body>
<h1>Une erreur est survenue sur le site {{ $appName }}</h1>
<h2>{{ $exception->getMessage() }}</h2>
<p>date : {{ date('Y-m-d H:i:s') }}</p>
<p>code : {{$exception->getCode() }}</p>
<p>file : {{$exception->getFile() }}</p>
<p>line : {{$exception->getLine() }}</p>
<p>Veuillez vérifier les logs pour en voir la trace.</p>
</body>
</html>

View file

@ -2,8 +2,8 @@
<p>Un nouveau ticket (n°{{ $ticket->id }}) a été créer sur le support web :</p>
<ul>
<li>Projet : {{ $ticket->project->name }}</li>
<li>Type : {{ $ticket->type === "0" ? "Bug" : "Évolution" }}</li>
<li>Créer par : {{ $ticket->user->name }}</li>
<li>Type : {{ $ticket->type->label }}</li>
<li>Créé par : {{ $ticket->user->name }}</li>
</ul>
<p>
Description :<br>

View file

@ -0,0 +1,9 @@
<p>Bonjour,</p>
<p>Vous avez été mentionné sur le ticket n°{{ $ticket->id }}:</p>
<ul>
<li>Projet : {{ $ticket->project->name }}</li>
<li>Type : {{ $ticket->type === 0 ? "Bug" : "Évolution" }}</li>
<li>Créé par : {{ $ticket->user->name }}</li>
</ul>
<p>{!! $corpsMaj !!}</p>
<p>Lien vers le ticket : <a href="{{ route('ticket.show', $ticket->id) }}" target="_blank">Ouvrir dans un nouvel onglet</a></p>

View file

@ -3,7 +3,7 @@
<ul>
<li>Projet : {{ $ticket->project->name }}</li>
<li>Type : {{ $ticket->type === 0 ? "Bug" : "Évolution" }}</li>
<li>Créer par : {{ $ticket->user->name }}</li>
<li>Créé par : {{ $ticket->user->name }}</li>
</ul>
<p>{!! $corpsMaj !!}</p>
<p>Lien vers le ticket : <a href="{{ route('ticket.show', $ticket->id) }}" target="_blank">Ouvrir dans un nouvel onglet</a></p>

View file

@ -4,9 +4,13 @@
<meta charset="utf-8">
<title>Support LDCWEB</title>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11.0.16/dist/sweetalert2.all.min.js" integrity="sha256-551HBsteMvKOSqjUXSmRy/EOF0bBNbWB96b5L3Deh8A=" crossorigin="anonymous"></script>
<!-- Styles -->
<link href="{{ asset('css/app.css') }}" rel="stylesheet">
<link href="{{ asset('css/style.css') }}" rel="stylesheet">
<link rel="stylesheet" href="https://bootswatch.com/5/lumen/bootstrap.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11.0.16/dist/sweetalert2.min.css" integrity="sha256-qpXwccFYncH5gTqXFe9EXZ8QeDw1Re68bbNVfFZzIzg=" crossorigin="anonymous">
</head>
<body>
<nav class="navbar navbar-expand-md navbar-light bg-white shadow-sm">
@ -19,36 +23,55 @@
@endif
</a>
@auth
@if (Auth::user()->role === 1)
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
@if (Auth::user()->role->equals(Role::admin()))
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link @yield('tickets')" href="{{ route('ticket.index') }}">Tickets</a>
<a class="nav-link @if(strpos(request()->route()->getName(), 'ticket') === 0)active @endif" href="{{ route('ticket.index') }}">Tickets</a>
</li>
<li class="nav-item">
<a class="nav-link @yield('project')" href="{{ route('project.index') }}">Projets</a>
<a class="nav-link @if(strpos(request()->route()->getName(), 'project') === 0)active @endif" href="{{ route('project.index') }}">Projets</a>
</li>
<li class="nav-item">
<a class="nav-link @if(strpos(request()->route()->getName(), 'users') === 0)active @endif" href="{{ route('users.index') }}">Utilisateurs</a>
</li>
</ul>
@endif
<ul class="navbar-nav me-auto">
<li class="nav-item">
<span class="nav-link text-primary font-weight-bold">Tickets</span>
</li>
<li class="nav-item">
<span class="nav-link text-dark">Nouveau&nbsp;:&nbsp;<span class="text-info">{{ $ticketsNew }}</span></span>
</li>
<li class="nav-item">
<span class="nav-link text-dark">En cours&nbsp;:&nbsp;<span class="text-info">{{ $ticketsInProgress }}</span></span>
</li>
<li class="nav-item">
{{-- Mouhahahaha --}}
<span class="nav-link text-dark">Charge&nbsp;:&nbsp;<span class="text-danger">Elevée</span></span>
{{-- <span class="nav-link text-dark">Charge&nbsp;:&nbsp;<span class="text-info">Stable</span></span> --}}
</li>
</ul>
</div>
@endif
@endauth
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="{{ __('Toggle navigation') }}">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="{{ __('Toggle navigation') }}">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<!-- Left Side Of Navbar -->
<ul class="navbar-nav mr-auto">
<ul class="navbar-nav me-auto">
</ul>
<!-- Right Side Of Navbar -->
<ul class="navbar-nav ml-auto">
<ul class="navbar-nav ms-auto">
<!-- Authentication Links -->
@auth
<li class="nav-item dropdown">
<a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" v-pre>
<a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" v-pre>
Bonjour {{ Auth::user()->name }} <span class="caret"></span>
</a>
@ -73,9 +96,13 @@
</div>
</div>
</nav>
<div class="content">
<div id="app" class="content mt-5">
@yield('content')
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js" integrity="sha512-894YE6QWD5I59HgZOGReFYm4dnWc1Qt5NtvYSaNcOP+u1T9qYdvdihz0PPSiiqn/+/3e7Jo4EaG7TubfWGUrMQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://bootswatch.com/_vendor/bootstrap/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
@stack('scriptsConstants')
<script src="{{ asset('js/app.js') }}"></script>
@stack('scripts')
</body>
</html>

View file

@ -0,0 +1,44 @@
@if ($message = Session::get('success'))
<div class="alert alert-success alert-dismissible fade show">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<strong>{!! nl2br(e($message)) !!}</strong>
</div>
@endif
@if ($message = Session::get('error'))
<div class="alert alert-danger alert-dismissible fade show">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<strong>{!! nl2br(e($message)) !!}</strong>
</div>
@endif
@if ($message = Session::get('warning'))
<div class="alert alert-warning alert-dismissible fade show">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<strong>{!! nl2br(e($message)) !!}</strong>
</div>
@endif
@if ($message = Session::get('info'))
<div class="alert alert-info alert-dismissible fade show">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<strong>{!! nl2br(e($message)) !!}</strong>
</div>
@endif
@if ($errors->any())
<div class="alert alert-danger alert-dismissible fade show">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
@if ($errors->any())
<ul class="list-unstyled">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
@endif
</div>
@endif

Some files were not shown because too many files have changed in this diff Show more