Skip to content

Commit ef0ee27

Browse files
committed
first stab
1 parent ae74a55 commit ef0ee27

File tree

14 files changed

+856
-0
lines changed

14 files changed

+856
-0
lines changed

app/Contracts/Http/Requests/RequestAttribute.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ class RequestAttribute
8383
public const FILE_ATTRIBUTE = 'file';
8484
public const SHALL_OVERRIDE_ATTRIBUTE = 'shall_override';
8585
public const IS_STARRED_ATTRIBUTE = 'is_starred';
86+
public const RATING_ATTRIBUTE = 'rating';
8687
public const DIRECTION_ATTRIBUTE = 'direction';
8788

8889
public const SINGLE_PATH_ATTRIBUTE = 'path';

app/Http/Controllers/Gallery/PhotoController.php

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@
1717
use App\Enum\FileStatus;
1818
use App\Enum\SizeVariantType;
1919
use App\Exceptions\ConfigurationException;
20+
use App\Exceptions\ConflictingPropertyException;
2021
use App\Http\Requests\Photo\CopyPhotosRequest;
2122
use App\Http\Requests\Photo\DeletePhotosRequest;
2223
use App\Http\Requests\Photo\EditPhotoRequest;
2324
use App\Http\Requests\Photo\FromUrlRequest;
2425
use App\Http\Requests\Photo\MovePhotosRequest;
2526
use App\Http\Requests\Photo\RenamePhotoRequest;
2627
use App\Http\Requests\Photo\RotatePhotoRequest;
28+
use App\Http\Requests\Photo\SetPhotoRatingRequest;
2729
use App\Http\Requests\Photo\SetPhotosStarredRequest;
2830
use App\Http\Requests\Photo\SetPhotosTagsRequest;
2931
use App\Http\Requests\Photo\UploadPhotoRequest;
@@ -36,7 +38,9 @@
3638
use App\Jobs\ExtractZip;
3739
use App\Jobs\ProcessImageJob;
3840
use App\Jobs\WatermarkerJob;
41+
use App\Models\PhotoRating;
3942
use App\Models\SizeVariant;
43+
use App\Models\Statistics;
4044
use App\Models\Tag;
4145
use App\Repositories\ConfigManager;
4246
use Illuminate\Routing\Controller;
@@ -166,6 +170,89 @@ public function star(SetPhotosStarredRequest $request): void
166170
}
167171
}
168172

173+
/**
174+
* Set the rating for a photo.
175+
*
176+
* @param SetPhotoRatingRequest $request
177+
*
178+
* @return PhotoResource
179+
*
180+
* @throws ConflictingPropertyException
181+
*/
182+
public function rate(SetPhotoRatingRequest $request): PhotoResource
183+
{
184+
try {
185+
DB::beginTransaction();
186+
187+
$photo = $request->photo();
188+
$user = Auth::user();
189+
$rating = $request->rating();
190+
191+
// Ensure statistics record exists atomically (Q001-07)
192+
$statistics = Statistics::firstOrCreate(
193+
['photo_id' => $photo->id],
194+
[
195+
'album_id' => null,
196+
'visit_count' => 0,
197+
'download_count' => 0,
198+
'favourite_count' => 0,
199+
'shared_count' => 0,
200+
'rating_sum' => 0,
201+
'rating_count' => 0,
202+
]
203+
);
204+
205+
if ($rating > 0) {
206+
// Find existing rating by this user for this photo
207+
$existingRating = PhotoRating::where('photo_id', $photo->id)
208+
->where('user_id', $user->id)
209+
->first();
210+
211+
if ($existingRating !== null) {
212+
// Update: adjust statistics delta
213+
$delta = $rating - $existingRating->rating;
214+
$statistics->rating_sum += $delta;
215+
$existingRating->rating = $rating;
216+
$existingRating->save();
217+
} else {
218+
// Insert: create new rating and increment statistics
219+
PhotoRating::create([
220+
'photo_id' => $photo->id,
221+
'user_id' => $user->id,
222+
'rating' => $rating,
223+
]);
224+
$statistics->rating_sum += $rating;
225+
$statistics->rating_count += 1;
226+
}
227+
228+
$statistics->save();
229+
} else {
230+
// Rating == 0: remove rating (idempotent, Q001-06)
231+
$existingRating = PhotoRating::where('photo_id', $photo->id)
232+
->where('user_id', $user->id)
233+
->first();
234+
235+
if ($existingRating !== null) {
236+
$statistics->rating_sum -= $existingRating->rating;
237+
$statistics->rating_count -= 1;
238+
$statistics->save();
239+
$existingRating->delete();
240+
}
241+
// If no existing rating, do nothing (idempotent)
242+
}
243+
244+
DB::commit();
245+
246+
// Reload photo with fresh statistics
247+
$photo->refresh();
248+
249+
return new PhotoResource($photo, null);
250+
} catch (\Throwable $e) {
251+
DB::rollBack();
252+
throw new ConflictingPropertyException('Failed to update photo rating due to a conflict. Please try again.', $e);
253+
}
254+
}
255+
169256
/**
170257
* Moves the photos to an album.
171258
*/
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2025 LycheeOrg.
7+
*/
8+
9+
namespace App\Http\Requests\Photo;
10+
11+
use App\Contracts\Http\Requests\HasPhoto;
12+
use App\Contracts\Http\Requests\RequestAttribute;
13+
use App\Http\Requests\BaseApiRequest;
14+
use App\Http\Requests\Traits\HasPhotoTrait;
15+
use App\Models\Photo;
16+
use App\Policies\PhotoPolicy;
17+
use App\Rules\RandomIDRule;
18+
use Illuminate\Support\Facades\Gate;
19+
20+
/**
21+
* Class SetPhotoRatingRequest.
22+
*/
23+
class SetPhotoRatingRequest extends BaseApiRequest implements HasPhoto
24+
{
25+
use HasPhotoTrait;
26+
27+
protected int $rating;
28+
29+
/**
30+
* {@inheritDoc}
31+
*/
32+
public function authorize(): bool
33+
{
34+
return Gate::check(PhotoPolicy::CAN_SEE, [Photo::class, $this->photo]);
35+
}
36+
37+
/**
38+
* {@inheritDoc}
39+
*/
40+
public function rules(): array
41+
{
42+
return [
43+
RequestAttribute::PHOTO_ID_ATTRIBUTE => ['required', new RandomIDRule(false)],
44+
RequestAttribute::RATING_ATTRIBUTE => 'required|integer|min:0|max:5',
45+
];
46+
}
47+
48+
/**
49+
* {@inheritDoc}
50+
*/
51+
protected function processValidatedValues(array $values, array $files): void
52+
{
53+
/** @var ?string $photo_id */
54+
$photo_id = $values[RequestAttribute::PHOTO_ID_ATTRIBUTE];
55+
$this->photo = Photo::query()
56+
->with(['albums'])
57+
->findOrFail($photo_id);
58+
$this->rating = intval($values[RequestAttribute::RATING_ATTRIBUTE]);
59+
}
60+
61+
public function rating(): int
62+
{
63+
return $this->rating;
64+
}
65+
}

app/Http/Resources/Models/PhotoResource.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ class PhotoResource extends Data
6666
private Carbon $timeline_data_carbon;
6767

6868
public ?PhotoStatisticsResource $statistics = null;
69+
public ?int $current_user_rating = null;
6970

7071
public function __construct(Photo $photo, ?AbstractAlbum $album)
7172
{
@@ -110,6 +111,14 @@ public function __construct(Photo $photo, ?AbstractAlbum $album)
110111
if (request()->configs()->getValueAsBool('metrics_enabled') && Gate::check(PhotoPolicy::CAN_READ_METRICS, [Photo::class, $photo])) {
111112
$this->statistics = PhotoStatisticsResource::fromModel($photo->statistics);
112113
}
114+
115+
// Load current user's rating if authenticated
116+
if (Auth::check()) {
117+
$userRating = $photo->ratings()
118+
->where('user_id', Auth::id())
119+
->first();
120+
$this->current_user_rating = $userRating?->rating;
121+
}
113122
}
114123

115124
// public static function fromModel(Photo $photo): PhotoResource

app/Http/Resources/Models/PhotoStatisticsResource.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ public function __construct(
2020
public int $download_count = 0,
2121
public int $favourite_count = 0,
2222
public int $shared_count = 0,
23+
public int $rating_count = 0,
24+
public ?float $rating_avg = null,
2325
) {
2426
}
2527

@@ -34,6 +36,8 @@ public static function fromModel(Statistics|null $stats): PhotoStatisticsResourc
3436
$stats->download_count,
3537
$stats->favourite_count,
3638
$stats->shared_count,
39+
$stats->rating_count,
40+
$stats->rating_avg,
3741
);
3842
}
3943
}

app/Models/Photo.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,16 @@ public function purchasable(): HasMany
230230
return $this->hasMany(Purchasable::class, 'photo_id', 'id');
231231
}
232232

233+
/**
234+
* Get all ratings for this photo.
235+
*
236+
* @return HasMany<PhotoRating,$this>
237+
*/
238+
public function ratings(): HasMany
239+
{
240+
return $this->hasMany(PhotoRating::class, 'photo_id', 'id');
241+
}
242+
233243
/**
234244
* Returns the relationship between a photo and its associated color palette.
235245
*

app/Models/PhotoRating.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2025 LycheeOrg.
7+
*/
8+
9+
namespace App\Models;
10+
11+
use App\Models\Extensions\ThrowsConsistentExceptions;
12+
use Illuminate\Database\Eloquent\Factories\HasFactory;
13+
use Illuminate\Database\Eloquent\Model;
14+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
15+
16+
/**
17+
* App\Models\PhotoRating.
18+
*
19+
* @property int $id
20+
* @property string $photo_id
21+
* @property int $user_id
22+
* @property int $rating
23+
* @property \Illuminate\Support\Carbon $created_at
24+
* @property \Illuminate\Support\Carbon $updated_at
25+
* @property Photo $photo
26+
* @property User $user
27+
*
28+
* @method static \Illuminate\Database\Eloquent\Builder|PhotoRating newModelQuery()
29+
* @method static \Illuminate\Database\Eloquent\Builder|PhotoRating newQuery()
30+
* @method static \Illuminate\Database\Eloquent\Builder|PhotoRating query()
31+
* @method static \Illuminate\Database\Eloquent\Builder|PhotoRating wherePhotoId($value)
32+
* @method static \Illuminate\Database\Eloquent\Builder|PhotoRating whereUserId($value)
33+
* @method static \Illuminate\Database\Eloquent\Builder|PhotoRating whereRating($value)
34+
*
35+
* @mixin \Eloquent
36+
*/
37+
class PhotoRating extends Model
38+
{
39+
use ThrowsConsistentExceptions;
40+
/** @phpstan-use HasFactory<\Database\Factories\PhotoRatingFactory> */
41+
use HasFactory;
42+
43+
protected $table = 'photo_ratings';
44+
45+
protected $fillable = [
46+
'photo_id',
47+
'user_id',
48+
'rating',
49+
];
50+
51+
protected $casts = [
52+
'rating' => 'integer',
53+
'user_id' => 'integer',
54+
];
55+
56+
/**
57+
* Get the photo that this rating belongs to.
58+
*
59+
* @return BelongsTo<Photo, PhotoRating>
60+
*/
61+
public function photo(): BelongsTo
62+
{
63+
return $this->belongsTo(Photo::class, 'photo_id', 'id');
64+
}
65+
66+
/**
67+
* Get the user who created this rating.
68+
*
69+
* @return BelongsTo<User, PhotoRating>
70+
*/
71+
public function user(): BelongsTo
72+
{
73+
return $this->belongsTo(User::class, 'user_id', 'id');
74+
}
75+
}

app/Models/Statistics.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
* @property int $download_count
2424
* @property int $favourite_count
2525
* @property int $shared_count
26+
* @property int $rating_sum
27+
* @property int $rating_count
28+
* @property float|null $rating_avg
2629
*
2730
* @method static StatisticsBuilder|Statistics addSelect($column)
2831
* @method static StatisticsBuilder|Statistics join(string $table, string $first, string $operator = null, string $second = null, string $type = 'inner', string $where = false)
@@ -65,5 +68,27 @@ public function newEloquentBuilder($query): StatisticsBuilder
6568
'download_count',
6669
'favourite_count',
6770
'shared_count',
71+
'rating_sum',
72+
'rating_count',
6873
];
74+
75+
protected $casts = [
76+
'rating_sum' => 'integer',
77+
'rating_count' => 'integer',
78+
];
79+
80+
/**
81+
* Get the average rating (sum / count).
82+
* Returns null if no ratings exist.
83+
*
84+
* @return float|null
85+
*/
86+
protected function getRatingAvgAttribute(): ?float
87+
{
88+
if ($this->rating_count === null || $this->rating_count === 0) {
89+
return null;
90+
}
91+
92+
return round($this->rating_sum / $this->rating_count, 2);
93+
}
6994
}

0 commit comments

Comments
 (0)