diff --git a/app/Console/Commands/OneOff/MigrateWorkdayData.php b/app/Console/Commands/OneOff/MigrateWorkdayData.php new file mode 100644 index 000000000..51b6089a6 --- /dev/null +++ b/app/Console/Commands/OneOff/MigrateWorkdayData.php @@ -0,0 +1,138 @@ +distinct()->pluck('workdayable_type'); + foreach ($entityTypes as $entityType) { + $entityIds = Workday::where('workdayable_type', $entityType) + ->select('workdayable_id') + ->distinct() + ->pluck('workdayable_id'); + $count = $entityIds->count(); + $shortName = explode_pop('\\', $entityType); + echo "Processing $shortName: $count records\n"; + + foreach ($entityIds as $entityId) { + $this->updateEntityWorkdays($entityType, $entityId); + } + } + } + + private function updateEntityWorkdays(string $entityType, int $entityId): void + { + $workdayCollections = Workday::where(['workdayable_type' => $entityType, 'workdayable_id' => $entityId]) + ->get() + ->reduce(function (array $carry, Workday $workday) { + $carry[$workday['collection']][] = $workday; + + return $carry; + }, []); + + foreach ($workdayCollections as $collection => $workdays) { + $mapping = $this->mapWorkdayCollection($workdays); + $framework_key = $workdays[0]->framework_key; + + $workday = Workday::create([ + 'workdayable_type' => $entityType, + 'workdayable_id' => $entityId, + 'framework_key' => $framework_key, + 'collection' => $collection, + ]); + foreach ($mapping as $demographic => $subTypes) { + foreach ($subTypes as $subType => $names) { + foreach ($names as $name => $amount) { + WorkdayDemographic::create([ + 'workday_id' => $workday->id, + 'type' => $demographic, + 'subtype' => $subType == self::SUBTYPE_NULL ? null : $subType, + 'name' => $name == self::NAME_NULL ? null : $name, + 'amount' => $amount, + ]); + } + } + } + + $workdayIds = collect($workdays)->map(fn ($workday) => $workday->id)->all(); + Workday::whereIn('id', $workdayIds)->update(['migrated_to_demographics' => true]); + Workday::whereIn('id', $workdayIds)->delete(); + } + } + + private function mapWorkdayCollection(array $workdays): array + { + $demographics = []; + foreach (self::DEMOGRAPHICS as $demographic) { + foreach ($workdays as $workday) { + $subType = $this->getSubtype($demographic, $workday); + $name = match ($workday[$demographic]) { + null, 'gender-undefined', 'age-undefined', 'decline-to-specify' => 'unknown', + default => $workday[$demographic], + }; + if ($subType == 'unknown' && strcasecmp($name, 'unknown') == 0) { + // We only get an unknown subtype when we're working on ethnicity. If the value is also unknown in + // this case, we want to leave it null. + $name = self::NAME_NULL; + } + + $current = data_get($demographics, "$demographic.$subType.$name"); + data_set($demographics, "$demographic.$subType.$name", $current + $workday->amount); + } + } + + return $demographics; + } + + private function getSubtype(string $demographic, Workday $workday): string + { + if ($demographic != WorkdayDemographic::ETHNICITY) { + return self::SUBTYPE_NULL; + } + + if ($workday->indigeneity != null && $workday->indigeneity != 'decline to specify') { + return $workday->indigeneity; + } + + if (Str::startsWith($workday->ethnicity, 'indigenous')) { + return 'indigenous'; + } elseif (Str::startsWith($workday->ethnicity, 'other')) { + return 'other'; + } + + return 'unknown'; + } +} diff --git a/app/Console/Commands/ReportWorkdayDiscrepancies.php b/app/Console/Commands/ReportWorkdayDiscrepancies.php new file mode 100644 index 000000000..152b3fe0e --- /dev/null +++ b/app/Console/Commands/ReportWorkdayDiscrepancies.php @@ -0,0 +1,93 @@ + [ + 'paid' => [ + Workday::COLLECTION_PROJECT_PAID_NURSERY_OPERATIONS, + Workday::COLLECTION_PROJECT_PAID_PROJECT_MANAGEMENT, + Workday::COLLECTION_PROJECT_PAID_OTHER, + ], + 'volunteer' => [ + Workday::COLLECTION_PROJECT_VOLUNTEER_NURSERY_OPERATIONS, + Workday::COLLECTION_PROJECT_VOLUNTEER_PROJECT_MANAGEMENT, + Workday::COLLECTION_PROJECT_VOLUNTEER_OTHER, + ], + ], + SiteReport::class => [ + 'paid' => [ + Workday::COLLECTION_SITE_PAID_SITE_ESTABLISHMENT, + Workday::COLLECTION_SITE_PAID_PLANTING, + Workday::COLLECTION_SITE_PAID_SITE_MAINTENANCE, + Workday::COLLECTION_SITE_PAID_SITE_MONITORING, + Workday::COLLECTION_SITE_PAID_OTHER, + ], + 'volunteer' => [ + Workday::COLLECTION_SITE_VOLUNTEER_SITE_ESTABLISHMENT, + Workday::COLLECTION_SITE_VOLUNTEER_PLANTING, + Workday::COLLECTION_SITE_VOLUNTEER_SITE_MAINTENANCE, + Workday::COLLECTION_SITE_VOLUNTEER_SITE_MONITORING, + Workday::COLLECTION_SITE_VOLUNTEER_OTHER, + ], + ], + ]; + + /** + * Execute the console command. + */ + public function handle() + { + echo "Model Type,Model UUID,Aggregate Paid Total,Disaggregate Paid Total,Aggregate Volunteer Total,Disaggregate Volunteer Total\n"; + foreach (self::PROPERTIES as $model => $propertySets) { + $model::where('status', 'approved')->chunkById( + 100, + function ($reports) use ($propertySets) { + foreach ($reports as $report) { + $aggregate_paid = (int)$report->workdays_paid; + $aggregate_volunteer = (int)$report->workdays_volunteer; + + $modelType = get_class($report); + $query = Workday::where([ + 'workdayable_type' => $modelType, + 'workdayable_id' => $report->id, + ]); + if ($query->count() == 0) { + // Skip reports that have no associated workday rows. + continue; + } + + $disaggregate_paid = (int)(clone $query)->whereIn('collection', $propertySets['paid'])->sum('amount'); + $disaggregate_volunteer = (int)(clone $query)->whereIn('collection', $propertySets['volunteer'])->sum('amount'); + + if ($aggregate_paid != $disaggregate_paid || $aggregate_volunteer != $disaggregate_volunteer) { + $shortType = explode_pop('\\', $modelType); + echo "$shortType,$report->uuid,$aggregate_paid,$disaggregate_paid,$aggregate_volunteer,$disaggregate_volunteer\n"; + } + } + } + ); + } + } +} diff --git a/app/Http/Controllers/V2/TreeSpecies/GetTreeSpeciesForEntityController.php b/app/Http/Controllers/V2/TreeSpecies/GetTreeSpeciesForEntityController.php index 45fa259f8..26f621b49 100644 --- a/app/Http/Controllers/V2/TreeSpecies/GetTreeSpeciesForEntityController.php +++ b/app/Http/Controllers/V2/TreeSpecies/GetTreeSpeciesForEntityController.php @@ -4,72 +4,20 @@ use App\Http\Controllers\Controller; use App\Http\Resources\V2\TreeSpecies\TreeSpeciesCollection; -use App\Models\V2\Nurseries\Nursery; -use App\Models\V2\Nurseries\NurseryReport; -use App\Models\V2\Projects\Project; -use App\Models\V2\Projects\ProjectReport; -use App\Models\V2\Sites\Site; -use App\Models\V2\Sites\SiteReport; +use App\Models\V2\EntityModel; use App\Models\V2\TreeSpecies\TreeSpecies; -use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class GetTreeSpeciesForEntityController extends Controller { - public function __invoke(Request $request, string $entity, string $uuid) + public function __invoke(Request $request, EntityModel $entity) { - $model = $this->getModel($entity); - - if (is_null($model)) { - return new JsonResponse($entity . ' is not a valid entity key', 422); - } - - $object = $model::isUuid($uuid)->first(); - - $this->authorize('read', $object); - - if (is_null($object)) { - return new JsonResponse($entity . ' record not found', 404); - } + $this->authorize('read', $entity); $query = TreeSpecies::query() - ->where('speciesable_type', $model) - ->where('speciesable_id', $object->id); + ->where('speciesable_type', get_class($entity)) + ->where('speciesable_id', $entity->id); return new TreeSpeciesCollection($query->paginate()); } - - private function getModel(string $entity) - { - $model = null; - - switch ($entity) { - case 'project': - $model = Project::class; - - break; - case 'site': - $model = Site::class; - - break; - case 'nursery': - $model = Nursery::class; - - break; - case 'project-report': - $model = ProjectReport::class; - - break; - case 'site-report': - $model = SiteReport::class; - - break; - case 'nursery-report': - $model = NurseryReport::class; - - break; - } - - return $model; - } } diff --git a/app/Http/Controllers/V2/Workdays/GetWorkdaysForEntityController.php b/app/Http/Controllers/V2/Workdays/GetWorkdaysForEntityController.php index ade27985f..3ba26e4ee 100644 --- a/app/Http/Controllers/V2/Workdays/GetWorkdaysForEntityController.php +++ b/app/Http/Controllers/V2/Workdays/GetWorkdaysForEntityController.php @@ -3,62 +3,44 @@ namespace App\Http\Controllers\V2\Workdays; use App\Http\Controllers\Controller; -use App\Http\Resources\V2\Workdays\WorkdaysCollection; -use App\Models\V2\Projects\ProjectReport; -use App\Models\V2\Sites\SiteReport; +use App\Http\Resources\V2\Workdays\WorkdayResource; +use App\Models\V2\EntityModel; use App\Models\V2\Workdays\Workday; -use Illuminate\Http\JsonResponse; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\Request; -use Spatie\QueryBuilder\AllowedFilter; -use Spatie\QueryBuilder\QueryBuilder; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class GetWorkdaysForEntityController extends Controller { - public function __invoke(Request $request, string $entity, string $uuid) + /** + * @throws AuthorizationException + * @throws \JsonException + */ + public function __invoke(Request $request, EntityModel $entity) { - $model = $this->getModel($entity); - $perPage = $request->query('per_page') ?? config('app.pagination_default', 15); - - if (is_null($model)) { - return new JsonResponse($entity . ' is not a valid entity key', 422); - } - - $object = $model::isUuid($uuid)->first(); - - $this->authorize('update', $object); - - if (is_null($object)) { - return new JsonResponse($entity . ' record not found', 404); - } - - $qry = QueryBuilder::for(Workday::class) - ->where('workdayable_type', $model) - ->where('workdayable_id', $object->id) - ->allowedFilters([ - AllowedFilter::exact('collection'), - ]); - - $totalAmount = $qry->sum('amount'); - - $collection = $qry->paginate($perPage) - ->appends(request()->query()); - - return (new WorkdaysCollection($collection))->params(['count_total' => $totalAmount ]); - } - - private function getModel(string $entity) - { - $model = null; - - switch ($entity) { - case 'project-report': - $model = ProjectReport::class; - - break; - case 'site-report': - $model = SiteReport::class; + $this->authorize('update', $entity); + + $workdays = Workday::where([ + 'workdayable_type' => get_class($entity), + 'workdayable_id' => $entity->id, + ])->get(); + + $expectedCollections = match ($entity->shortName) { + 'site-report' => array_keys(Workday::SITE_COLLECTIONS), + 'project-report' => array_keys(Workday::PROJECT_COLLECTION), + default => throw new NotFoundHttpException(), + }; + $collections = $workdays->pluck('collection'); + foreach ($expectedCollections as $collection) { + if (! $collections->contains($collection)) { + $workday = new Workday(); + // Allows the resource to return an API response with no demographics, but still containing + // the collection and readable collection name. + $workday['collection'] = $collection; + $workdays->push($workday); + } } - return $model; + return WorkdayResource::collection($workdays); } } diff --git a/app/Http/Middleware/ModelInterfaceBindingMiddleware.php b/app/Http/Middleware/ModelInterfaceBindingMiddleware.php index 87118f1f4..53ab7f0ab 100644 --- a/app/Http/Middleware/ModelInterfaceBindingMiddleware.php +++ b/app/Http/Middleware/ModelInterfaceBindingMiddleware.php @@ -53,8 +53,12 @@ class ModelInterfaceBindingMiddleware private static array $typeSlugsCache = []; - public static function with(string $interface, callable $routeGroup, string $prefix = null, string $modelParameter = null): RouteRegistrar - { + public static function with( + string $interface, + callable $routeGroup, + string $prefix = null, + string $modelParameter = null, + ): RouteRegistrar { $typeSlugs = self::$typeSlugsCache[$interface] ?? []; if (empty($typeSlugs)) { foreach (self::CONCRETE_MODELS as $slug => $concrete) { @@ -66,11 +70,22 @@ public static function with(string $interface, callable $routeGroup, string $pre self::$typeSlugsCache[$interface] = $typeSlugs; } - $middleware = $modelParameter == null ? 'modelInterface' : "modelInterface:$modelParameter"; + return self::forSlugs($typeSlugs, $routeGroup, $prefix, $modelParameter); + } + /** + * @param array $typeSlugs The type slugs in use must be defined in CONCRETE_MODELS for the middleware + * to function. + */ + public static function forSlugs( + array $typeSlugs, + callable $routeGroup, + string $prefix = null, + string $modelParameter = null, + ): RouteRegistrar { return Route::prefix("$prefix/{modelSlug}") ->whereIn('modelSlug', $typeSlugs) - ->middleware($middleware) + ->middleware($modelParameter == null ? 'modelInterface' : "modelInterface:$modelParameter") ->group($routeGroup); } diff --git a/app/Http/Requests/V2/Workdays/StoreWorkdayRequest.php b/app/Http/Requests/V2/Workdays/StoreWorkdayRequest.php deleted file mode 100644 index 480e379b4..000000000 --- a/app/Http/Requests/V2/Workdays/StoreWorkdayRequest.php +++ /dev/null @@ -1,28 +0,0 @@ - 'required|string|in:organisation,project-pitch,site,site-report,project,project-report,nursery,nursery-report', - 'model_uuid' => 'required|string', - 'amount' => 'sometimes|nullable|integer|between:0,2147483647', - 'collection' => 'sometimes|nullable|string|in:' . implode(',', array_keys(array_merge(Workday::$siteCollections, Workday::$projectCollections))), - 'gender' => 'sometimes|nullable|string|between:1,255', - 'ethnicity' => 'sometimes|nullable|string|between:1,255', - 'indigeneity' => 'sometimes|nullable|string|between:1,255', - 'age' => 'sometimes|nullable|string|between:1,255', - ]; - } -} diff --git a/app/Http/Requests/V2/Workdays/UpdateWorkdayRequest.php b/app/Http/Requests/V2/Workdays/UpdateWorkdayRequest.php deleted file mode 100644 index f2c962eec..000000000 --- a/app/Http/Requests/V2/Workdays/UpdateWorkdayRequest.php +++ /dev/null @@ -1,26 +0,0 @@ - 'sometimes|nullable|integer|between:0,2147483647', - 'collection' => 'sometimes|nullable|string|in:' . implode(',', array_keys(array_merge(Workday::$siteCollections, Workday::$projectCollections))), - 'gender' => 'sometimes|nullable|string|between:1,255', - 'ethnicity' => 'sometimes|nullable|string|between:1,255', - 'indigeneity' => 'sometimes|nullable|string|between:1,255', - 'age' => 'sometimes|nullable|string|between:1,255', - ]; - } -} diff --git a/app/Http/Resources/V2/Workdays/WorkdayDemographicResource.php b/app/Http/Resources/V2/Workdays/WorkdayDemographicResource.php new file mode 100644 index 000000000..6a95f0a91 --- /dev/null +++ b/app/Http/Resources/V2/Workdays/WorkdayDemographicResource.php @@ -0,0 +1,18 @@ + $this->type, + 'subtype' => $this->subtype, + 'name' => $this->name, + 'amount' => $this->amount, + ]; + } +} diff --git a/app/Http/Resources/V2/Workdays/WorkdayResource.php b/app/Http/Resources/V2/Workdays/WorkdayResource.php index b81b7734e..aaaceaec0 100644 --- a/app/Http/Resources/V2/Workdays/WorkdayResource.php +++ b/app/Http/Resources/V2/Workdays/WorkdayResource.php @@ -6,21 +6,13 @@ class WorkdayResource extends JsonResource { - /** - * @param Request $request - * @return array - */ public function toArray($request) { return [ 'uuid' => $this->uuid, 'collection' => $this->collection, 'readable_collection' => $this->readable_collection, - 'amount' => $this->amount, - 'gender' => $this->gender, - 'age' => $this->age, - 'ethnicity' => $this->ethnicity, - 'indigeneity' => $this->indigeneity, + 'demographics' => empty($this->demographics) ? [] : WorkdayDemographicResource::collection($this->demographics), ]; } } diff --git a/app/Http/Resources/V2/Workdays/WorkdaysCollection.php b/app/Http/Resources/V2/Workdays/WorkdaysCollection.php deleted file mode 100644 index 2960f3a1a..000000000 --- a/app/Http/Resources/V2/Workdays/WorkdaysCollection.php +++ /dev/null @@ -1,29 +0,0 @@ -params = $params; - - return $this; - } - - public function toArray($request) - { - return ['data' => WorkdayResource::collection($this->collection)]; - } - - public function paginationInformation($request, $paginated, $default) - { - $default['meta']['count_total'] = data_get($this->params, 'count_total'); - - return $default; - } -} diff --git a/app/Models/Interfaces/HandlesLinkedFieldSync.php b/app/Models/Interfaces/HandlesLinkedFieldSync.php new file mode 100644 index 000000000..781cc9822 --- /dev/null +++ b/app/Models/Interfaces/HandlesLinkedFieldSync.php @@ -0,0 +1,10 @@ +workdays()->collection($collection); + } + ); + } + } + + public function workdays() + { + return $this->morphMany(Workday::class, 'workdayable'); + } + + public function getOtherWorkdaysDescriptionAttribute(): ?string + { + return $this + ->workdays() + ->whereIn('collection', self::OTHER_WORKDAY_COLLECTIONS) + ->orderBy('updated_at', 'desc') + ->select('description') + ->first() + ?->description; + } + + public function setOtherWorkdaysDescriptionAttribute(?string $value): void + { + $workdaysQuery = $this->morphMany(Workday::class, 'workdayable'); + if (! empty($value)) { + foreach (self::OTHER_WORKDAY_COLLECTIONS as $collection) { + if (! (clone $workdaysQuery)->where('collection', $collection)->exists()) { + Workday::create([ + 'workdayable_type' => get_class($this), + 'workdayable_id' => $this->id, + 'collection' => $collection, + ]); + } + } + } + $workdaysQuery + ->whereIn('collection', self::OTHER_WORKDAY_COLLECTIONS) + ->update(['description' => $value]); + } +} diff --git a/app/Models/Traits/UsesLinkedFields.php b/app/Models/Traits/UsesLinkedFields.php index f04d4cb6d..7a4d13b80 100644 --- a/app/Models/Traits/UsesLinkedFields.php +++ b/app/Models/Traits/UsesLinkedFields.php @@ -2,6 +2,7 @@ namespace App\Models\Traits; +use App\Models\Interfaces\HandlesLinkedFieldSync; use App\Models\V2\Forms\Form; trait UsesLinkedFields @@ -127,10 +128,13 @@ public function calculateCompletion(Form $form): int continue; } - if (! empty($answer['conditionalOn']) && - ! $answers[$answer['conditionalOn']]['value']) { - // don't count it if the question wasn't shown to the user because the parent conditional is false. - continue; + if (! empty($answers['conditionalOn'])) { + $conditional = $answers['conditional']; + if (empty($conditional) || ! $conditional['value']) { + // don't count it if the question wasn't shown to the user because the parent conditional is false + // or missing + continue; + } } $questionCount++; @@ -250,6 +254,13 @@ private function syncRelation(string $property, string $inputType, $data): void return; } + $class = get_class($this->$property()->make()); + if (is_a($class, HandlesLinkedFieldSync::class, true)) { + $class::syncRelation($this, $property, $data); + + return; + } + $this->$property()->whereNotIn('uuid', $data->pluck('uuid')->filter())->delete(); // This would be better as a bulk operation, but too much processing is required to make that feasible diff --git a/app/Models/V2/Projects/ProjectReport.php b/app/Models/V2/Projects/ProjectReport.php index 6bfbf8a86..52526197e 100644 --- a/app/Models/V2/Projects/ProjectReport.php +++ b/app/Models/V2/Projects/ProjectReport.php @@ -10,6 +10,7 @@ use App\Models\Traits\HasUpdateRequests; use App\Models\Traits\HasUuid; use App\Models\Traits\HasV2MediaCollections; +use App\Models\Traits\HasWorkdays; use App\Models\Traits\UsesLinkedFields; use App\Models\V2\MediaModel; use App\Models\V2\Nurseries\Nursery; @@ -52,6 +53,7 @@ class ProjectReport extends Model implements MediaModel, AuditableContract, Repo use HasUpdateRequests; use HasEntityResources; use BelongsToThroughTrait; + use HasWorkdays; protected $auditInclude = [ 'status', @@ -141,6 +143,9 @@ class ProjectReport extends Model implements MediaModel, AuditableContract, Repo 'local_engagement', 'site_addition', 'paid_other_activity_description', + + // virtual (see HasWorkdays trait) + 'other_workdays_description', ]; public $casts = [ @@ -173,6 +178,13 @@ class ProjectReport extends Model implements MediaModel, AuditableContract, Repo ], ]; + // Required by the HasWorkdays trait + public const WORKDAY_COLLECTIONS = Workday::PROJECT_COLLECTION; + public const OTHER_WORKDAY_COLLECTIONS = [ + Workday::COLLECTION_PROJECT_PAID_OTHER, + Workday::COLLECTION_PROJECT_VOLUNTEER_OTHER, + ]; + public function registerMediaConversions(Media $media = null): void { $this->addMediaConversion('thumbnail') @@ -239,36 +251,6 @@ public function treeSpecies() return $this->morphMany(TreeSpecies::class, 'speciesable'); } - public function workdaysPaidNurseryOperations() - { - return $this->morphMany(Workday::class, 'workdayable')->where('collection', Workday::COLLECTION_PROJECT_PAID_NURSERY_OPRERATIONS); - } - - public function workdaysPaidProjectManagement() - { - return $this->morphMany(Workday::class, 'workdayable')->where('collection', Workday::COLLECTION_PROJECT_PAID_PROJECT_MANAGEMENT); - } - - public function workdaysPaidOtherActivities() - { - return $this->morphMany(Workday::class, 'workdayable')->where('collection', Workday::COLLECTION_PROJECT_PAID_OTHER); - } - - public function workdaysVolunteerNurseryOperations() - { - return $this->morphMany(Workday::class, 'workdayable')->where('collection', Workday::COLLECTION_PROJECT_VOLUNTEER_NURSERY_OPRERATIONS); - } - - public function workdaysVolunteerProjectManagement() - { - return $this->morphMany(Workday::class, 'workdayable')->where('collection', Workday::COLLECTION_PROJECT_VOLUNTEER_PROJECT_MANAGEMENT); - } - - public function workdaysVolunteerOtherActivities() - { - return $this->morphMany(Workday::class, 'workdayable')->where('collection', Workday::COLLECTION_PROJECT_VOLUNTEER_OTHER); - } - /** Calculated Values */ public function getTaskUuidAttribute(): ?string { diff --git a/app/Models/V2/Sites/SiteReport.php b/app/Models/V2/Sites/SiteReport.php index 99dc5d110..48e80e8a3 100644 --- a/app/Models/V2/Sites/SiteReport.php +++ b/app/Models/V2/Sites/SiteReport.php @@ -11,6 +11,7 @@ use App\Models\Traits\HasUpdateRequests; use App\Models\Traits\HasUuid; use App\Models\Traits\HasV2MediaCollections; +use App\Models\Traits\HasWorkdays; use App\Models\Traits\UsesLinkedFields; use App\Models\V2\Disturbance; use App\Models\V2\Invasive; @@ -56,6 +57,7 @@ class SiteReport extends Model implements MediaModel, AuditableContract, ReportM use HasUpdateRequests; use HasEntityResources; use BelongsToThroughTrait; + use HasWorkdays; protected $auditInclude = [ 'status', @@ -91,6 +93,9 @@ class SiteReport extends Model implements MediaModel, AuditableContract, ReportM 'polygon_status', 'answers', 'paid_other_activity_description', + + // virtual (see HasWorkdays trait) + 'other_workdays_description', ]; public $fileConfiguration = [ @@ -139,6 +144,13 @@ class SiteReport extends Model implements MediaModel, AuditableContract, ReportM 'answers' => 'array', ]; + // Required by the HasWorkdays trait + public const WORKDAY_COLLECTIONS = Workday::SITE_COLLECTIONS; + public const OTHER_WORKDAY_COLLECTIONS = [ + Workday::COLLECTION_SITE_PAID_OTHER, + Workday::COLLECTION_SITE_VOLUNTEER_OTHER, + ]; + public function registerMediaConversions(Media $media = null): void { $this->addMediaConversion('thumbnail') @@ -216,56 +228,6 @@ public function invasive() return $this->morphMany(Invasive::class, 'invasiveable'); } - public function workdaysPaidSiteEstablishment() - { - return $this->morphMany(Workday::class, 'workdayable')->where('collection', Workday::COLLECTION_SITE_PAID_SITE_ESTABLISHMENT); - } - - public function workdaysPaidPlanting() - { - return $this->morphMany(Workday::class, 'workdayable')->where('collection', Workday::COLLECTION_SITE_PAID_PLANTING); - } - - public function workdaysPaidSiteMaintenance() - { - return $this->morphMany(Workday::class, 'workdayable')->where('collection', Workday::COLLECTION_SITE_PAID_SITE_MAINTENANCE); - } - - public function workdaysPaidSiteMonitoring() - { - return $this->morphMany(Workday::class, 'workdayable')->where('collection', Workday::COLLECTION_SITE_PAID_SITE_MONITORING); - } - - public function workdaysPaidOtherActivities() - { - return $this->morphMany(Workday::class, 'workdayable')->where('collection', Workday::COLLECTION_SITE_PAID_OTHER); - } - - public function workdaysVolunteerSiteEstablishment() - { - return $this->morphMany(Workday::class, 'workdayable')->where('collection', Workday::COLLECTION_SITE_VOLUNTEER_SITE_ESTABLISHMENT); - } - - public function workdaysVolunteerPlanting() - { - return $this->morphMany(Workday::class, 'workdayable')->where('collection', Workday::COLLECTION_SITE_VOLUNTEER_PLANTING); - } - - public function workdaysVolunteerSiteMaintenance() - { - return $this->morphMany(Workday::class, 'workdayable')->where('collection', Workday::COLLECTION_SITE_VOLUNTEER_SITE_MAINTENANCE); - } - - public function workdaysVolunteerSiteMonitoring() - { - return $this->morphMany(Workday::class, 'workdayable')->where('collection', Workday::COLLECTION_SITE_VOLUNTEER_SITE_MONITORING); - } - - public function workdaysVolunteerOtherActivities() - { - return $this->morphMany(Workday::class, 'workdayable')->where('collection', Workday::COLLECTION_SITE_VOLUNTEER_OTHER); - } - public function approvedBy(): HasOne { return $this->hasOne(User::class, 'id', 'approved_by'); diff --git a/app/Models/V2/Workdays/Workday.php b/app/Models/V2/Workdays/Workday.php index bb1c3e0b1..038f1cf27 100644 --- a/app/Models/V2/Workdays/Workday.php +++ b/app/Models/V2/Workdays/Workday.php @@ -2,13 +2,21 @@ namespace App\Models\V2\Workdays; +use App\Models\Interfaces\HandlesLinkedFieldSync; use App\Models\Traits\HasTypes; use App\Models\Traits\HasUuid; +use App\Models\V2\EntityModel; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Collection; -class Workday extends Model +/** + * @property Collection $demographics + */ +class Workday extends Model implements HandlesLinkedFieldSync { use HasFactory; use SoftDeletes; @@ -31,20 +39,22 @@ class Workday extends Model 'age', 'ethnicity', 'indigeneity', + 'migrated_to_demographics', + 'description', ]; - public const COLLECTION_PROJECT_PAID_NURSERY_OPRERATIONS = 'paid-nursery-operations'; + public const COLLECTION_PROJECT_PAID_NURSERY_OPERATIONS = 'paid-nursery-operations'; public const COLLECTION_PROJECT_PAID_PROJECT_MANAGEMENT = 'paid-project-management'; public const COLLECTION_PROJECT_PAID_OTHER = 'paid-other-activities'; - public const COLLECTION_PROJECT_VOLUNTEER_NURSERY_OPRERATIONS = 'volunteer-nursery-operations'; + public const COLLECTION_PROJECT_VOLUNTEER_NURSERY_OPERATIONS = 'volunteer-nursery-operations'; public const COLLECTION_PROJECT_VOLUNTEER_PROJECT_MANAGEMENT = 'volunteer-project-management'; public const COLLECTION_PROJECT_VOLUNTEER_OTHER = 'volunteer-other-activities'; - public static $projectCollections = [ - self::COLLECTION_PROJECT_PAID_NURSERY_OPRERATIONS => 'Paid Nursery Operations', + public const PROJECT_COLLECTION = [ + self::COLLECTION_PROJECT_PAID_NURSERY_OPERATIONS => 'Paid Nursery Operations', self::COLLECTION_PROJECT_PAID_PROJECT_MANAGEMENT => 'Paid Project Management', self::COLLECTION_PROJECT_PAID_OTHER => 'Paid Other Activities', - self::COLLECTION_PROJECT_VOLUNTEER_NURSERY_OPRERATIONS => 'Volunteer Nursery Operations', + self::COLLECTION_PROJECT_VOLUNTEER_NURSERY_OPERATIONS => 'Volunteer Nursery Operations', self::COLLECTION_PROJECT_VOLUNTEER_PROJECT_MANAGEMENT => 'Volunteer Project Management', self::COLLECTION_PROJECT_VOLUNTEER_OTHER => 'Volunteer Other Activities', ]; @@ -60,7 +70,7 @@ class Workday extends Model public const COLLECTION_SITE_VOLUNTEER_SITE_MONITORING = 'volunteer-site-monitoring'; public const COLLECTION_SITE_VOLUNTEER_OTHER = 'volunteer-other-activities'; - public static $siteCollections = [ + public const SITE_COLLECTIONS = [ self::COLLECTION_SITE_PAID_SITE_ESTABLISHMENT => 'Paid Site Establishment', self::COLLECTION_SITE_PAID_PLANTING => 'Paid Planting', self::COLLECTION_SITE_PAID_SITE_MAINTENANCE => 'Paid Site Maintenance', @@ -73,6 +83,60 @@ class Workday extends Model self::COLLECTION_SITE_VOLUNTEER_OTHER => 'Volunteer Other Activities', ]; + /** + * @throws \Exception + */ + public static function syncRelation(EntityModel $entity, string $property, $data): void + { + if (count($data) == 0) { + $entity->$property()->delete(); + + return; + } + + // Workdays only have one instance per collection + $workdayData = $data[0]; + $workday = $entity->$property()->first(); + if ($workday != null && $workday->collection != $workdayData['collection']) { + throw new \Exception( + 'Workday collection does not match entity property [' . + 'property collection: ' . $workday->collection . ', ' . + 'submitted collection: ' . $workdayData['collection'] . ']' + ); + } + + if ($workday == null) { + $workday = Workday::create([ + 'workdayable_type' => get_class($entity), + 'workdayable_id' => $entity->id, + 'collection' => $workdayData['collection'], + ]); + } + + $demographics = $workday->demographics; + $represented = collect(); + foreach ($workdayData['demographics'] as $demographicData) { + $demographic = $demographics->firstWhere([ + 'type' => data_get($demographicData, 'type'), + 'subtype' => data_get($demographicData, 'subtype'), + 'name' => data_get($demographicData, 'name'), + ]); + + if ($demographic == null) { + $workday->demographics()->create($demographicData); + } else { + $represented->push($demographic->id); + $demographic->update(['amount' => data_get($demographicData, 'amount')]); + } + } + // Remove any existing demographic that wasn't in the submitted set. + foreach ($demographics as $demographic) { + if (! $represented->contains($demographic->id)) { + $demographic->delete(); + } + } + } + public function workdayable() { return $this->morphTo(); @@ -83,12 +147,22 @@ public function getRouteKeyName() return 'uuid'; } + public function scopeCollection(Builder $query, string $collection): Builder + { + return $query->where('collection', $collection); + } + + public function demographics(): HasMany + { + return $this->hasMany(WorkdayDemographic::class); + } + public function getReadableCollectionAttribute(): ?string { if (empty($this->collection)) { return null; } - return data_get(array_merge(static::$projectCollections, static::$siteCollections), $this->collection, 'Unknown'); + return data_get(array_merge(static::PROJECT_COLLECTION, static::SITE_COLLECTIONS), $this->collection, 'Unknown'); } } diff --git a/app/Models/V2/Workdays/WorkdayDemographic.php b/app/Models/V2/Workdays/WorkdayDemographic.php new file mode 100644 index 000000000..7acd120c5 --- /dev/null +++ b/app/Models/V2/Workdays/WorkdayDemographic.php @@ -0,0 +1,63 @@ +belongsTo(Workday::class); + } + + public function scopeGender(Builder $query): Builder + { + return $query->where('type', self::GENDER); + } + + public function scopeIsGender(Builder $query, string $gender): Builder + { + return $query->where(['type' => self::GENDER, 'name' => $gender]); + } + + public function scopeAge(Builder $query): Builder + { + return $query->where('type', self::AGE); + } + + public function scopeIsAge(Builder $query, string $age): Builder + { + return $query->where(['type' => self::AGE, 'name' => $age]); + } + + public function scopeEthnicity(Builder $query): Builder + { + return $query->where('type', self::ETHNICITY); + } + + public function scopeIsEthnicity(Builder $query, string $ethnicity, string $name = null): Builder + { + return $query->where(['type' => self::ETHNICITY, 'subtype' => $ethnicity, 'name' => $name]); + } +} diff --git a/config/wri/linked-fields.php b/config/wri/linked-fields.php index e4a2484c8..e5d5aec2c 100644 --- a/config/wri/linked-fields.php +++ b/config/wri/linked-fields.php @@ -414,7 +414,9 @@ 'pro-rep-equitable-opportunities' => ['property' => 'equitable_opportunities', 'label' => 'Equitable Opportunities for Women + Youth', 'input_type' => 'long-text'], 'pro-rep-local-engagement' => ['property' => 'local_engagement', 'label' => 'Community Engagement Approach', 'input_type' => 'select', 'multichoice' => false, 'option_list_key' => 'local-engagement'], 'pro-rep-site-addition' => ['property' => 'site_addition', 'label' => 'Site Addition', 'input_type' => 'boolean'], + // TODO (TM-912) Deprecated, to be removed. 'pro-rep-paid-other-activity-description' => ['property' => 'paid_other_activity_description', 'label' => 'Paid Other Activities Description', 'input_type' => 'long-text'], + 'pro-rep-other-workdays-description' => ['property' => 'other_workdays_description', 'label' => 'Other Activities Description', 'input_type' => 'long-text'], ], 'relations' => [ 'pro-rep-rel-tree-species' => [ @@ -574,7 +576,9 @@ 'site-rep-seeds-planted' => ['property' => 'seeds_planted', 'label' => 'Seeds planted', 'input_type' => 'number'], 'site-rep-workdays-volunteer' => ['property' => 'workdays_volunteer', 'label' => 'Workdays volunteer', 'input_type' => 'number'], 'site-rep-polygon-status' => ['property' => 'polygon_status', 'label' => 'Polygon status', 'input_type' => 'long-text'], + // TODO (TM-912) Deprecated, to be removed. 'site-rep-paid-other-activity-description' => ['property' => 'paid_other_activity_description', 'label' => 'Paid Other Activities Description', 'input_type' => 'long-text'], + 'site-rep-other-workdays-description' => ['property' => 'other_workdays_description', 'label' => 'Other Activities Description', 'input_type' => 'long-text'], ], 'file-collections' => [ 'site-rep-col-media' => ['property' => 'media', 'label' => 'Media', 'input_type' => 'file', 'multichoice' => true], diff --git a/database/factories/V2/Workdays/WorkdayDemographicFactory.php b/database/factories/V2/Workdays/WorkdayDemographicFactory.php new file mode 100644 index 000000000..685528506 --- /dev/null +++ b/database/factories/V2/Workdays/WorkdayDemographicFactory.php @@ -0,0 +1,60 @@ + Workday::factory()->create()->id, + 'type' => 'gender', + 'subtype' => null, + 'name' => $this->faker->randomElement(self::GENDERS), + 'amount' => $this->faker->randomNumber([0, 5000]), + ]; + } + + public function gender() + { + return $this->state(function (array $attributes) { + return [ + 'type' => WorkdayDemographic::GENDER, + 'name' => $this->faker->randomElement(self::GENDERS), + ]; + }); + } + + public function age() + { + return $this->state(function (array $attributes) { + return [ + 'type' => WorkdayDemographic::AGE, + 'name' => $this->faker->randomElement(self::AGES), + ]; + }); + } + + public function ethnicity() + { + return $this->state(function (array $attributes) { + return [ + 'type' => WorkdayDemographic::ETHNICITY, + 'subtype' => $this->faker->randomElement(self::ETHNICITIES), + ]; + }); + } +} diff --git a/database/factories/V2/Workdays/WorkdayFactory.php b/database/factories/V2/Workdays/WorkdayFactory.php index 843904fa1..e6a19f119 100644 --- a/database/factories/V2/Workdays/WorkdayFactory.php +++ b/database/factories/V2/Workdays/WorkdayFactory.php @@ -15,19 +15,11 @@ class WorkdayFactory extends Factory */ public function definition() { - $gender = ['female', 'male', 'gender-undefined']; - $age = ['youth-15-24', 'adult-24-65', 'elder-65+', 'age-undefined']; - $ethnicity = ['middle-eastern', 'hispanic', 'irish', 'native-american', 'Jewish', 'pacific-islander', 'ethnicity-undefined']; - - return [ + 'uuid' => $this->faker->uuid(), 'workdayable_type' => SiteReport::class, 'workdayable_id' => SiteReport::factory()->create(), - 'amount' => $this->faker->numberBetween(0, 5000), - 'collection' => $this->faker->randomElement(Workday::$siteCollections), - 'gender' => $this->faker->randomElement($gender), - 'age' => $this->faker->randomElement($age), - 'ethnicity' => $this->faker->randomElement($ethnicity), + 'collection' => $this->faker->randomElement(array_keys(Workday::SITE_COLLECTIONS)), ]; } } diff --git a/database/migrations/2024_04_19_225021_create_workday_demographics.php b/database/migrations/2024_04_19_225021_create_workday_demographics.php new file mode 100644 index 000000000..e3d742fb5 --- /dev/null +++ b/database/migrations/2024_04_19_225021_create_workday_demographics.php @@ -0,0 +1,34 @@ +id(); + $table->foreignIdFor(Workday::class); + $table->string('type'); + $table->string('subtype')->nullable(); + $table->string('name')->nullable(); + $table->integer('amount'); + + $table->softDeletes(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workday_demographics'); + } +}; diff --git a/database/migrations/2024_04_25_222046_add_migrated_to_workday.php b/database/migrations/2024_04_25_222046_add_migrated_to_workday.php new file mode 100644 index 000000000..5c3b0a230 --- /dev/null +++ b/database/migrations/2024_04_25_222046_add_migrated_to_workday.php @@ -0,0 +1,27 @@ +boolean('migrated_to_demographics')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v2_workdays', function (Blueprint $table) { + $table->dropColumn('migrated_to_demographics'); + }); + } +}; diff --git a/database/migrations/2024_05_13_194243_add_description_to_workdays.php b/database/migrations/2024_05_13_194243_add_description_to_workdays.php new file mode 100644 index 000000000..fd4a5b1b9 --- /dev/null +++ b/database/migrations/2024_05_13_194243_add_description_to_workdays.php @@ -0,0 +1,27 @@ +text('description')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v2_workdays', function (Blueprint $table) { + $table->dropColumn('description'); + }); + } +}; diff --git a/openapi-src/V2/definitions/V2WorkdayRead.yml b/openapi-src/V2/definitions/V2WorkdayRead.yml index 0e551805c..582cfc5a6 100644 --- a/openapi-src/V2/definitions/V2WorkdayRead.yml +++ b/openapi-src/V2/definitions/V2WorkdayRead.yml @@ -3,15 +3,11 @@ type: object properties: uuid: type: string - amount: - type: integer collection: type: string - gender: + readable_collection: type: string - age: - type: string - ethnicity: - type: string - indigeneity: - type: string \ No newline at end of file + demographics: + type: array + items: + $ref: './_index.yml#/WorkdayDemographic' diff --git a/openapi-src/V2/definitions/V2WorkdaysPaginated.yml b/openapi-src/V2/definitions/V2WorkdaysPaginated.yml deleted file mode 100644 index 1265f48c2..000000000 --- a/openapi-src/V2/definitions/V2WorkdaysPaginated.yml +++ /dev/null @@ -1,30 +0,0 @@ -type: object -properties: - data: - type: array - items: - $ref: './_index.yml#/V2WorkdayRead' - links: - type: object - properties: - first: - type: string - last: - type: string - prev: - type: string - next: - type: string - meta: - type: object - properties: - current_page: - type: integer - from: - type: integer - last_page: - type: integer - next: - type: integer - unfiltered_total: - type: integer diff --git a/openapi-src/V2/definitions/WorkdayDemographic.yml b/openapi-src/V2/definitions/WorkdayDemographic.yml new file mode 100644 index 000000000..9e3f7af9f --- /dev/null +++ b/openapi-src/V2/definitions/WorkdayDemographic.yml @@ -0,0 +1,12 @@ +title: WorkdayDemographic +type: object +properties: + type: + type: string + enum: [gender, age, ethnicity] + subtype: + type: string + name: + type: string + amount: + type: integer diff --git a/openapi-src/V2/definitions/_index.yml b/openapi-src/V2/definitions/_index.yml index 7fb47dd61..fcff7d24b 100644 --- a/openapi-src/V2/definitions/_index.yml +++ b/openapi-src/V2/definitions/_index.yml @@ -152,8 +152,6 @@ V2SeedingPaginated: $ref: './V2SeedingPaginated.yml' V2WorkdayRead: $ref: './V2WorkdayRead.yml' -V2WorkdaysPaginated: - $ref: './V2WorkdaysPaginated.yml' V2DisturbanceRead: $ref: './V2DisturbanceRead.yml' V2DisturbanceCreate: @@ -272,6 +270,8 @@ V2ProjectInviteRead: $ref: './V2ProjectInviteRead.yml' V2ProjectInviteCreate: $ref: './V2ProjectInviteCreate.yml' +WorkdayDemographic: + $ref: './WorkdayDemographic.yml' GeoJSON: $ref: './GeoJSON.yml' SiteGeometryPost: diff --git a/openapi-src/V2/paths/Workdays/get-v2-workdays-entity-uuid.yml b/openapi-src/V2/paths/Workdays/get-v2-workdays-entity-uuid.yml index c4d5f1fd9..b88b41b92 100644 --- a/openapi-src/V2/paths/Workdays/get-v2-workdays-entity-uuid.yml +++ b/openapi-src/V2/paths/Workdays/get-v2-workdays-entity-uuid.yml @@ -7,7 +7,7 @@ parameters: name: ENTITY in: path required: true - description: allowed values project/site/nursery/project-reports/site-reports/nursery-reports + description: allowed values project-report/site-report - type: string name: UUID in: path @@ -16,4 +16,9 @@ responses: '200': description: OK schema: - $ref: '../../definitions/_index.yml#/V2WorkdaysPaginated' + type: object + properties: + data: + type: array + items: + $ref: '../../definitions/_index.yml#/V2WorkdayRead' diff --git a/resources/docs/swagger-v2.yml b/resources/docs/swagger-v2.yml index a3a3f21ed..aa91d215f 100644 --- a/resources/docs/swagger-v2.yml +++ b/resources/docs/swagger-v2.yml @@ -9254,65 +9254,28 @@ definitions: properties: uuid: type: string - amount: - type: integer collection: type: string - gender: - type: string - age: - type: string - ethnicity: - type: string - indigeneity: + readable_collection: type: string - V2WorkdaysPaginated: - type: object - properties: - data: + demographics: type: array items: - title: V2WorkdayRead + title: WorkdayDemographic type: object properties: - uuid: - type: string - amount: - type: integer - collection: - type: string - gender: - type: string - age: + type: type: string - ethnicity: + enum: + - gender + - age + - ethnicity + subtype: type: string - indigeneity: + name: type: string - links: - type: object - properties: - first: - type: string - last: - type: string - prev: - type: string - next: - type: string - meta: - type: object - properties: - current_page: - type: integer - from: - type: integer - last_page: - type: integer - next: - type: integer - unfiltered_total: - type: integer + amount: + type: integer V2DisturbanceRead: title: V2DisturbanceRead type: object @@ -43997,6 +43960,22 @@ definitions: properties: email_address: type: string + WorkdayDemographic: + title: WorkdayDemographic + type: object + properties: + type: + type: string + enum: + - gender + - age + - ethnicity + subtype: + type: string + name: + type: string + amount: + type: integer GeoJSON: title: GeoJSON type: object @@ -57663,7 +57642,7 @@ paths: name: ENTITY in: path required: true - description: allowed values project/site/nursery/project-reports/site-reports/nursery-reports + description: allowed values project-report/site-report - type: string name: UUID in: path @@ -57682,42 +57661,28 @@ paths: properties: uuid: type: string - amount: - type: integer collection: type: string - gender: - type: string - age: - type: string - ethnicity: - type: string - indigeneity: + readable_collection: type: string - links: - type: object - properties: - first: - type: string - last: - type: string - prev: - type: string - next: - type: string - meta: - type: object - properties: - current_page: - type: integer - from: - type: integer - last_page: - type: integer - next: - type: integer - unfiltered_total: - type: integer + demographics: + type: array + items: + title: WorkdayDemographic + type: object + properties: + type: + type: string + enum: + - gender + - age + - ethnicity + subtype: + type: string + name: + type: string + amount: + type: integer /v2/stratas: post: operationId: post-v2-stratas diff --git a/routes/api_v2.php b/routes/api_v2.php index b304399a6..4261cc69d 100644 --- a/routes/api_v2.php +++ b/routes/api_v2.php @@ -452,13 +452,13 @@ Route::put('/submit/{projectPitch}', SubmitProjectPitchController::class); }); -Route::prefix('tree-species')->group(function () { - Route::get('/{entity}/{uuid}', GetTreeSpeciesForEntityController::class); -}); +ModelInterfaceBindingMiddleware::with(EntityModel::class, function () { + Route::get('/{entity}', GetTreeSpeciesForEntityController::class); +}, prefix: 'tree-species'); -Route::prefix('workdays')->group(function () { - Route::get('/{entity}/{uuid}', GetWorkdaysForEntityController::class); -}); +ModelInterfaceBindingMiddleware::forSlugs(['project-report', 'site-report'], function () { + Route::get('/{entity}', GetWorkdaysForEntityController::class); +}, prefix: 'workdays'); Route::prefix('stratas')->group(function () { Route::post('/', StoreStrataController::class); @@ -532,12 +532,9 @@ Route::put('/{task}/submit', SubmitProjectTasksController::class); }); -Route::prefix('{modelSlug}') - ->whereIn('modelSlug', ['site-reports', 'nursery-reports']) - ->middleware('modelInterface') - ->group(function () { - Route::put('/{report}/nothing-to-report', NothingToReportReportController::class); - }); +ModelInterfaceBindingMiddleware::forSlugs(['site-reports', 'nursery-reports'], function () { + Route::put('/{report}/nothing-to-report', NothingToReportReportController::class); +}); ModelInterfaceBindingMiddleware::with(EntityModel::class, function () { Route::get('/{entity}', ViewEntityController::class); diff --git a/tests/Unit/Models/V2/Workdays/WorkdayTest.php b/tests/Unit/Models/V2/Workdays/WorkdayTest.php new file mode 100644 index 000000000..46b2c7815 --- /dev/null +++ b/tests/Unit/Models/V2/Workdays/WorkdayTest.php @@ -0,0 +1,55 @@ +create(); + + // First, test adding workdays to an empty set + $data = [ + [ + 'collection' => Workday::COLLECTION_SITE_VOLUNTEER_PLANTING, + 'demographics' => [ + ['type' => 'age', 'name' => 'youth', 'amount' => 20], + ['type' => 'gender', 'name' => 'non-binary', 'amount' => 20], + ['type' => 'ethnicity', 'subtype' => 'other', 'amount' => 20], + ], + ], + ]; + Workday::syncRelation($siteReport, 'workdaysVolunteerPlanting', $data); + + $workday = $siteReport->workdaysVolunteerPlanting()->first(); + $this->assertEquals(3, $workday->demographics()->count()); + $this->assertEquals(20, $workday->demographics()->isAge('youth')->first()->amount); + $this->assertEquals(20, $workday->demographics()->isGender('non-binary')->first()->amount); + $this->assertEquals(20, $workday->demographics()->isEthnicity('other')->first()->amount); + + // Test modifying an existing demographic collection + $data[0]['demographics'] = [ + ['type' => 'age', 'name' => 'youth', 'amount' => 40], + ['type' => 'gender', 'name' => 'non-binary', 'amount' => 20], + ['type' => 'gender', 'name' => 'female', 'amount' => 20], + ['type' => 'ethnicity', 'subtype' => 'indigenous', 'name' => 'Ohlone', 'amount' => 40], + ]; + Workday::syncRelation($siteReport->fresh(), 'workdaysVolunteerPlanting', $data); + $workday->refresh(); + $this->assertEquals(4, $workday->demographics()->count()); + $this->assertEquals(40, $workday->demographics()->isAge('youth')->first()->amount); + $this->assertEquals(20, $workday->demographics()->isGender('non-binary')->first()->amount); + $this->assertEquals(20, $workday->demographics()->isGender('female')->first()->amount); + $this->assertEquals(40, $workday->demographics()->isEthnicity('indigenous', 'Ohlone')->first()->amount); + + // Test remove demographics + $data[0]['demographics'] = []; + Workday::syncRelation($siteReport->fresh(), 'workdaysVolunteerPlanting', $data); + $workday->refresh(); + $this->assertEquals(0, $workday->demographics()->count()); + } +} diff --git a/tests/V2/Workdays/GetWorkdaysForEntityControllerTest.php b/tests/V2/Workdays/GetWorkdaysForEntityControllerTest.php index f6d94db26..9c79db07b 100644 --- a/tests/V2/Workdays/GetWorkdaysForEntityControllerTest.php +++ b/tests/V2/Workdays/GetWorkdaysForEntityControllerTest.php @@ -8,6 +8,7 @@ use App\Models\V2\Sites\Site; use App\Models\V2\Sites\SiteReport; use App\Models\V2\Workdays\Workday; +use App\Models\V2\Workdays\WorkdayDemographic; use App\StateMachines\EntityStatusStateMachine; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Artisan; @@ -17,7 +18,7 @@ class GetWorkdaysForEntityControllerTest extends TestCase { use RefreshDatabase; - public function test_invoke_action() + public function test_empty_workdays_response() { Artisan::call('v2migration:roles'); $organisation = Organisation::factory()->create(); @@ -43,19 +44,102 @@ public function test_invoke_action() 'status' => EntityStatusStateMachine::STARTED, ]); + $uri = '/api/v2/workdays/site-report/' . $report->uuid; + + $this->actingAs($user) + ->getJson($uri) + ->assertStatus(403); + + // The endpoint should return a workday for each collection with empty demographics for each + $response = $this->actingAs($owner) + ->getJson($uri) + ->assertSuccessful() + ->assertJsonCount(count(Workday::SITE_COLLECTIONS), 'data') + ->decodeResponseJson(); + foreach ($response['data'] as $workday) { + $this->assertCount(0, $workday['demographics']); + } + } + + public function test_populated_workdays() + { + Artisan::call('v2migration:roles'); + $organisation = Organisation::factory()->create(); + $owner = User::factory()->create(['organisation_id' => $organisation->id]); + $owner->givePermissionTo('manage-own'); + + $project = Project::factory()->create([ + 'organisation_id' => $organisation->id, + 'framework_key' => 'ppc', + ]); + + $site = Site::factory()->create([ + 'project_id' => $project->id, + 'framework_key' => 'ppc', + 'status' => EntityStatusStateMachine::STARTED, + ]); + + $report = SiteReport::factory()->create([ + 'site_id' => $site->id, + 'framework_key' => 'ppc', + 'status' => EntityStatusStateMachine::STARTED, + ]); + $workday = Workday::factory()->create([ - 'workdayable_type' => SiteReport::class, 'workdayable_id' => $report->id, ]); + $femaleCount = WorkdayDemographic::factory()->gender()->create([ + 'workday_id' => $workday->id, + 'name' => 'female', + ])->amount; + $nonBinaryCount = WorkdayDemographic::factory()->gender()->create([ + 'workday_id' => $workday->id, + 'name' => 'non-binary', + ])->amount; + $youthCount = WorkdayDemographic::factory()->age()->create([ + 'workday_id' => $workday->id, + 'name' => 'youth', + ])->amount; + $otherAgeCount = WorkdayDemographic::factory()->age()->create([ + 'workday_id' => $workday->id, + 'name' => 'other', + ])->amount; + $indigenousCount = WorkdayDemographic::factory()->ethnicity()->create([ + 'workday_id' => $workday->id, + 'subtype' => 'indigenous', + 'name' => 'Ohlone', + ])->amount; $uri = '/api/v2/workdays/site-report/' . $report->uuid; - $this->actingAs($user) + $response = $this->actingAs($owner) ->getJson($uri) - ->assertStatus(403); + ->assertSuccessful() + ->assertJsonCount(count(Workday::SITE_COLLECTIONS), 'data') + ->decodeResponseJson(); + $foundCollection = false; + foreach ($response['data'] as $workdayData) { + $demographics = $workdayData['demographics']; + if ($workdayData['collection'] != $workday->collection) { + $this->assertCount(0, $demographics); - $this->actingAs($owner) - ->getJson($uri) - ->assertSuccessful(); + continue; + } + + $foundCollection = true; + $this->assertCount(5, $demographics); + + // They should be in creation order + $expected = [ + ['type' => 'gender', 'subtype' => null, 'name' => 'female', 'amount' => $femaleCount], + ['type' => 'gender', 'subtype' => null, 'name' => 'non-binary', 'amount' => $nonBinaryCount], + ['type' => 'age', 'subtype' => null, 'name' => 'youth', 'amount' => $youthCount], + ['type' => 'age', 'subtype' => null, 'name' => 'other', 'amount' => $otherAgeCount], + ['type' => 'ethnicity', 'subtype' => 'indigenous', 'name' => 'Ohlone', 'amount' => $indigenousCount], + ]; + $this->assertEquals($expected, $demographics); + } + + $this->assertTrue($foundCollection); } }