Skip to content

Commit 358f414

Browse files
authored
Merge pull request #3425 from codeeu/week_10_feb
Week 10 feb
2 parents af01e46 + 9fc9c75 commit 358f414

File tree

5 files changed

+313
-2
lines changed

5 files changed

+313
-2
lines changed
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Excellence;
6+
use Illuminate\Console\Command;
7+
8+
class CertificateFillEmptyNames extends Command
9+
{
10+
protected $signature = 'certificate:fill-empty-names
11+
{--edition=2025 : Target edition year}
12+
{--type=all : excellence|super-organiser|all}
13+
{--fallback=email : email = use local part of email; placeholder = use "Certificate Holder"}
14+
{--placeholder="Certificate Holder" : When fallback=placeholder, this value is used}
15+
{--limit=0 : Max rows to update (0 = all)}
16+
{--dry-run : Only report, do not update}';
17+
18+
protected $description = 'Set name_for_certificate for Excellence rows where it is empty (so certs can generate)';
19+
20+
public function handle(): int
21+
{
22+
$edition = (int) $this->option('edition');
23+
$typeOption = strtolower(trim((string) $this->option('type')));
24+
$fallback = strtolower(trim((string) $this->option('fallback')));
25+
$placeholder = trim((string) $this->option('placeholder')) ?: 'Certificate Holder';
26+
$limit = max(0, (int) $this->option('limit'));
27+
$dryRun = (bool) $this->option('dry-run');
28+
29+
$types = $this->resolveTypes($typeOption);
30+
if ($types === null) {
31+
$this->error("Invalid --type value: {$typeOption}. Use 'excellence', 'super-organiser', or 'all'.");
32+
return self::FAILURE;
33+
}
34+
35+
$query = Excellence::query()
36+
->where('edition', $edition)
37+
->whereIn('type', $types)
38+
->where(function ($q) {
39+
$q->whereNull('name_for_certificate')->orWhere('name_for_certificate', '');
40+
})
41+
->with('user')
42+
->orderBy('id');
43+
44+
if ($limit > 0) {
45+
$query->limit($limit);
46+
}
47+
48+
$rows = $query->get();
49+
if ($rows->isEmpty()) {
50+
$this->info('No rows with empty name_for_certificate found.');
51+
return self::SUCCESS;
52+
}
53+
54+
$this->info('Rows with empty name: ' . $rows->count() . ($dryRun ? ' (dry-run, no changes)' : ''));
55+
$updated = 0;
56+
57+
foreach ($rows as $e) {
58+
$user = $e->user;
59+
$email = $user?->email ?? '';
60+
$name = $this->fallbackName($fallback, $placeholder, $email, $user);
61+
if ($name === '') {
62+
$name = 'Certificate Holder';
63+
}
64+
$name = $this->truncateName($name, 40);
65+
66+
if (! $dryRun) {
67+
$e->update(['name_for_certificate' => $name]);
68+
}
69+
$updated++;
70+
$this->line(" " . ($dryRun ? 'Would set' : 'Set') . " excellence id {$e->id} ({$email}) => " . $name);
71+
}
72+
73+
$this->newLine();
74+
$this->info($dryRun ? "Dry-run: would update {$updated} row(s). Run without --dry-run to apply." : "Updated {$updated} row(s).");
75+
76+
return self::SUCCESS;
77+
}
78+
79+
private function resolveTypes(string $typeOption): ?array
80+
{
81+
return match ($typeOption) {
82+
'all' => ['Excellence', 'SuperOrganiser'],
83+
'excellence' => ['Excellence'],
84+
'super-organiser', 'superorganiser' => ['SuperOrganiser'],
85+
default => null,
86+
};
87+
}
88+
89+
private function fallbackName(string $fallback, string $placeholder, string $email, $user): string
90+
{
91+
if ($fallback === 'placeholder') {
92+
return $placeholder;
93+
}
94+
if ($email === '') {
95+
return $placeholder;
96+
}
97+
$local = explode('@', $email)[0] ?? '';
98+
$local = preg_replace('/[^a-zA-Z0-9._-]/', ' ', $local);
99+
$local = str_replace(['.', '_', '-'], ' ', $local);
100+
$local = ucwords(strtolower(trim(preg_replace('/\s+/', ' ', $local))));
101+
return $local !== '' ? $local : $placeholder;
102+
}
103+
104+
private function truncateName(string $name, int $max = 40): string
105+
{
106+
if (mb_strlen($name) <= $max) {
107+
return $name;
108+
}
109+
return mb_substr($name, 0, $max - 1) . '';
110+
}
111+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Excellence;
6+
use Illuminate\Console\Command;
7+
use Illuminate\Support\Facades\DB;
8+
9+
class CertificateOrphanedExcellence extends Command
10+
{
11+
protected $signature = 'certificate:orphaned-excellence
12+
{--edition=2025 : Target edition year}
13+
{--type=all : excellence|super-organiser|all}
14+
{--ids= : Comma-separated excellence IDs to check (optional)}
15+
{--export= : CSV path to export orphaned rows}
16+
{--delete : Delete orphaned excellence rows (cannot send cert without user)}';
17+
18+
protected $description = 'List or fix Excellence rows whose user record is missing (Missing related user record)';
19+
20+
public function handle(): int
21+
{
22+
$edition = (int) $this->option('edition');
23+
$typeOption = strtolower(trim((string) $this->option('type')));
24+
$idsOption = trim((string) $this->option('ids'));
25+
$exportPath = trim((string) $this->option('export'));
26+
$delete = (bool) $this->option('delete');
27+
28+
$types = $this->resolveTypes($typeOption);
29+
if ($types === null) {
30+
$this->error("Invalid --type value: {$typeOption}. Use 'excellence', 'super-organiser', or 'all'.");
31+
return self::FAILURE;
32+
}
33+
34+
$query = Excellence::query()
35+
->where('edition', $edition)
36+
->whereIn('type', $types)
37+
->whereNotExists(function ($q) {
38+
$q->select(DB::raw(1))
39+
->from('users')
40+
->whereColumn('users.id', 'excellences.user_id');
41+
})
42+
->orderBy('id');
43+
44+
if ($idsOption !== '') {
45+
$ids = array_filter(array_map('intval', explode(',', $idsOption)));
46+
if (! empty($ids)) {
47+
$query->whereIn('id', $ids);
48+
}
49+
}
50+
51+
$rows = $query->get();
52+
if ($rows->isEmpty()) {
53+
$this->info('No orphaned Excellence rows found (all have a related user).');
54+
return self::SUCCESS;
55+
}
56+
57+
$this->warn('Orphaned Excellence rows (user_id points to missing user): ' . $rows->count());
58+
$this->table(
59+
['id', 'user_id', 'type', 'edition', 'name_for_certificate'],
60+
$rows->map(fn ($e) => [$e->id, $e->user_id, $e->type, $e->edition, $e->name_for_certificate ?? ''])->toArray()
61+
);
62+
63+
if ($exportPath !== '') {
64+
$path = str_starts_with($exportPath, '/') ? $exportPath : base_path($exportPath);
65+
$dir = dirname($path);
66+
if (! is_dir($dir) && ! @mkdir($dir, 0775, true) && ! is_dir($dir)) {
67+
$this->error("Failed to create directory: {$dir}");
68+
return self::FAILURE;
69+
}
70+
$fh = @fopen($path, 'wb');
71+
if (! $fh) {
72+
$this->error("Failed to open: {$path}");
73+
return self::FAILURE;
74+
}
75+
fputcsv($fh, ['excellence_id', 'user_id', 'type', 'edition', 'name_for_certificate']);
76+
foreach ($rows as $e) {
77+
fputcsv($fh, [$e->id, $e->user_id, $e->type, $e->edition, $e->name_for_certificate ?? '']);
78+
}
79+
fclose($fh);
80+
$this->info("Exported: {$path}");
81+
}
82+
83+
if ($delete) {
84+
$ids = $rows->pluck('id')->toArray();
85+
Excellence::query()->whereIn('id', $ids)->delete();
86+
$this->info('Deleted ' . count($ids) . ' orphaned Excellence row(s). They will no longer appear in preflight or send.');
87+
} else {
88+
$this->line('Tip: Use --delete to remove these rows so they are excluded from certificate generation/send.');
89+
}
90+
91+
return self::SUCCESS;
92+
}
93+
94+
private function resolveTypes(string $typeOption): ?array
95+
{
96+
return match ($typeOption) {
97+
'all' => ['Excellence', 'SuperOrganiser'],
98+
'excellence' => ['Excellence'],
99+
'super-organiser', 'superorganiser' => ['SuperOrganiser'],
100+
default => null,
101+
};
102+
}
103+
}

app/Console/Commands/CertificatePreflight.php

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ class CertificatePreflight extends Command
1414
{--limit=0 : Max records to test (0 = all)}
1515
{--batch-size=500 : Process in batches; 0 = single run}
1616
{--only-pending : Test only rows without certificate_url}
17+
{--emails= : Comma-separated emails to test only}
18+
{--emails-file= : Path to file with one email per line (to test only those)}
1719
{--export= : Optional CSV path for failures (only failures written)}';
1820

1921
protected $description = 'Dry-run compile certificates (no S3 upload, no DB updates) and report failures';
@@ -25,6 +27,8 @@ public function handle(): int
2527
$limit = max(0, (int) $this->option('limit'));
2628
$batchSize = max(0, (int) $this->option('batch-size'));
2729
$onlyPending = (bool) $this->option('only-pending');
30+
$emailsOption = trim((string) $this->option('emails'));
31+
$emailsFilePath = trim((string) $this->option('emails-file'));
2832
$exportPath = trim((string) $this->option('export'));
2933

3034
$types = $this->resolveTypes($typeOption);
@@ -33,23 +37,36 @@ public function handle(): int
3337
return self::FAILURE;
3438
}
3539

40+
$emailList = $this->resolveEmailList($emailsOption, $emailsFilePath);
41+
if ($emailList === null) {
42+
return self::FAILURE;
43+
}
44+
3645
$baseQuery = Excellence::query()
3746
->where('edition', $edition)
3847
->whereIn('type', $types)
3948
->with('user')
4049
->orderBy('type')
4150
->orderBy('id');
4251

52+
if (! empty($emailList)) {
53+
$baseQuery->whereHas('user', fn ($q) => $q->whereIn('email', $emailList));
54+
}
55+
4356
if ($onlyPending) {
4457
$baseQuery->whereNull('certificate_url');
4558
}
4659

4760
$totalToTest = (clone $baseQuery)->count();
4861
if ($totalToTest === 0) {
49-
$this->info('No recipients found for the selected filters.');
62+
$this->info(empty($emailList) ? 'No recipients found for the selected filters.' : 'No Excellence rows found for the given email list (edition/type may not match).');
5063
return self::SUCCESS;
5164
}
5265

66+
if (! empty($emailList)) {
67+
$this->info('Restricted to '.count($emailList).' email(s) from list; '.$totalToTest.' Excellence row(s) match.');
68+
}
69+
5370
if ($limit > 0) {
5471
$totalToTest = min($totalToTest, $limit);
5572
}
@@ -178,6 +195,36 @@ public function handle(): int
178195
return self::SUCCESS;
179196
}
180197

198+
/**
199+
* @return array<string>|null Returns list of emails, or null on error (e.g. file not found).
200+
*/
201+
private function resolveEmailList(string $emailsOption, string $emailsFilePath): ?array
202+
{
203+
$list = [];
204+
if ($emailsOption !== '') {
205+
foreach (array_map('trim', explode(',', $emailsOption)) as $e) {
206+
if ($e !== '') {
207+
$list[] = $e;
208+
}
209+
}
210+
}
211+
if ($emailsFilePath !== '') {
212+
$path = str_starts_with($emailsFilePath, '/') ? $emailsFilePath : base_path($emailsFilePath);
213+
if (! is_file($path) || ! is_readable($path)) {
214+
$this->error("Emails file not found or not readable: {$path}");
215+
return null;
216+
}
217+
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
218+
foreach ($lines as $line) {
219+
$e = trim($line);
220+
if ($e !== '') {
221+
$list[] = $e;
222+
}
223+
}
224+
}
225+
return array_values(array_unique($list));
226+
}
227+
181228
private function resolveTypes(string $typeOption): ?array
182229
{
183230
return match ($typeOption) {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\CertificateExcellence;
6+
use Illuminate\Console\Command;
7+
use Illuminate\Support\Facades\Storage;
8+
9+
class CertificateTestTwo extends Command
10+
{
11+
protected $signature = 'certificate:test-two
12+
{--edition=2025 : Edition year}
13+
{--no-pdf : Only run preflight (no S3), do not keep PDFs}';
14+
15+
protected $description = 'Generate two test certificates locally: one Greek name, one Russian name';
16+
17+
public function handle(): int
18+
{
19+
$edition = (int) $this->option('edition');
20+
$preflightOnly = (bool) $this->option('no-pdf');
21+
22+
$tests = [
23+
['name' => 'Βασιλική Μπαμπαλή', 'label' => 'Greek'],
24+
['name' => 'Иван Петров', 'label' => 'Russian'],
25+
];
26+
27+
foreach ($tests as $test) {
28+
$this->info("Testing {$test['label']}: {$test['name']}");
29+
$cert = new CertificateExcellence($edition, $test['name'], 'excellence', 0, 999999, 'test@example.com');
30+
$this->line(' is_greek(): ' . ($cert->is_greek() ? 'true' : 'false'));
31+
32+
try {
33+
if ($preflightOnly) {
34+
$cert->preflight();
35+
$this->info(" Preflight OK (no PDF kept).");
36+
} else {
37+
$url = $cert->generate();
38+
$this->info(" Generated: {$url}");
39+
}
40+
} catch (\Throwable $e) {
41+
$this->error(' Failed: ' . $e->getMessage());
42+
return self::FAILURE;
43+
}
44+
}
45+
46+
$this->newLine();
47+
$this->info('Both certificates OK. Greek uses _greek template; Russian uses default template.');
48+
return self::SUCCESS;
49+
}
50+
}

resources/latex/excellence_greek-2025.tex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
\vspace{8.3cm}
4141

4242
{\centering\Huge\
43-
\begin{otherlanguage*}{russian}
43+
\begin{otherlanguage*}{greek}
4444
\textbf{<CERTIFICATE_HOLDER_NAME>}
4545
\end{otherlanguage*}
4646
\par}

0 commit comments

Comments
 (0)