Compare commits

..

10 Commits

10 changed files with 236 additions and 87 deletions

View File

@@ -4,6 +4,7 @@
@inherits ModuleBase
@inject IEngineerApplicationService ApplicationService
@inject NavigationManager NavManager
@inject Oqtane.Services.IUserService UserService
@if (Oqtane.Security.UserSecurity.IsAuthorized(PageState.User, RoleNames.Admin) || Oqtane.Security.UserSecurity.IsAuthorized(PageState.User, "Premium Member"))
{
@@ -16,7 +17,7 @@
else if (_applications.Count == 0)
{
<div class="alert alert-warning">
Keine genehmigten Anträge gefunden.
Keine Anträge gefunden.
</div>
}
else
@@ -27,16 +28,42 @@
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Ingenieur-Antrag</h5>
<h6 class="card-subtitle mb-2 text-muted">Benutzer ID: @app.UserId</h6>
<p class="card-text">
<strong>Datei:</strong> @app.FileId<br/>
<strong>Status:</strong> <span class="badge bg-success">Veröffentlicht</span><br/>
<strong>Datum:</strong> @(app.ApprovedOn?.ToShortDateString() ?? app.CreatedOn.ToShortDateString())
<div class="d-flex justify-content-between align-items-start">
<h5 class="card-title mb-0">@(string.IsNullOrEmpty(app.Title) ? "Ingenieur-Antrag" : app.Title)</h5>
@if (Oqtane.Security.UserSecurity.IsAuthorized(PageState.User, RoleNames.Admin))
{
@if (_confirmDeleteId == app.ApplicationId)
{
<div class="d-flex gap-1">
<button class="btn btn-danger btn-sm" @onclick="@(() => DeleteApp(app))" title="Bestätigen">
<span class="oi oi-check"></span>
</button>
<button class="btn btn-secondary btn-sm" @onclick="@(() => _confirmDeleteId = -1)" title="Abbrechen">
<span class="oi oi-x"></span>
</button>
</div>
}
else
{
<button class="btn btn-outline-danger btn-sm" @onclick="@(() => _confirmDeleteId = app.ApplicationId)" title="Antrag löschen">
<span class="oi oi-trash"></span>
</button>
}
}
</div>
<h6 class="card-subtitle mb-2 text-muted mt-1">von @GetUserName(app.UserId)</h6>
@if (!string.IsNullOrEmpty(app.ShortDescription))
{
<p class="card-text">@app.ShortDescription</p>
}
<p class="card-text text-muted">
<small>
<strong>Datum:</strong> @(app.ApprovedOn?.ToShortDateString() ?? app.CreatedOn.ToShortDateString())
</small>
</p>
<div class="d-flex gap-2">
<button class="btn btn-primary btn-sm" @onclick="@(async () => ShowDetail(app))">PDF ansehen</button>
<a href="@((NavManager.BaseUri + "api/engineerapplication") + "/download/" + app.ApplicationId + "?moduleid=" + ModuleState.ModuleId)" target="_blank" class="btn btn-outline-secondary btn-sm">Herunterladen</a>
<a href="@(PageState.Alias.Path == "" ? "" : "/" + PageState.Alias.Path)/api/file/download/@(app.FileId)/attach" target="_blank" class="btn btn-outline-secondary btn-sm">Herunterladen</a>
<button class="btn btn-outline-danger btn-sm" @onclick="@(() => InitReport(app))">Melden</button>
</div>
</div>
@@ -59,12 +86,12 @@ else
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Antrags-PDF (@_selectedApp.FileId)</h5>
<h5 class="modal-title">Antrags-PDF</h5>
<button type="button" class="btn-close" @onclick="@(() => _selectedApp = null)"></button>
</div>
<div class="modal-body p-0">
<div class="ratio ratio-16x9" style="min-height: 500px;">
<iframe src="@((NavManager.BaseUri + "api/engineerapplication") + "/download/" + _selectedApp.ApplicationId + "?moduleid=" + ModuleState.ModuleId)" allowfullscreen></iframe>
<div style="min-height: 600px; height: 75vh;">
<iframe src="@(PageState.Alias.Path == "" ? "" : "/" + PageState.Alias.Path)/api/file/download/@(_selectedApp.FileId)" style="width: 100%; height: 100%; border: none;" allowfullscreen></iframe>
</div>
</div>
<div class="modal-footer">
@@ -86,7 +113,7 @@ else
<button type="button" class="btn-close" @onclick="@(() => _reportApp = null)"></button>
</div>
<div class="modal-body">
<p>Bitte geben Sie einen Grund an, warum Sie diesen Antrag melden (Benutzer ID: @_reportApp.UserId, Datei: @_reportApp.FileId).</p>
<p>Bitte geben Sie einen Grund an, warum Sie diesen Antrag melden (von @GetUserName(_reportApp.UserId)).</p>
<textarea class="form-control" rows="3" @bind="_reportReason" placeholder="Grund..."></textarea>
</div>
<div class="modal-footer">
@@ -106,6 +133,8 @@ else
private EngineerApplication _selectedApp;
private EngineerApplication _reportApp;
private string _reportReason;
private Dictionary<int, string> _userNames = new();
private int _confirmDeleteId = -1;
protected override async Task OnInitializedAsync()
{
@@ -119,6 +148,20 @@ else
if (approved != null) _applications.AddRange(approved);
_applications = _applications.GroupBy(a => a.ApplicationId).Select(g => g.First()).ToList();
// Benutzernamen laden
foreach (var userId in _applications.Select(a => a.UserId).Distinct())
{
try
{
var user = await UserService.GetUserAsync(userId, ModuleState.SiteId);
_userNames[userId] = user?.DisplayName ?? $"Benutzer {userId}";
}
catch
{
_userNames[userId] = $"Benutzer {userId}";
}
}
}
catch (Exception ex)
{
@@ -154,4 +197,25 @@ else
}
}
private async Task DeleteApp(EngineerApplication app)
{
try
{
await ApplicationService.DeleteApplicationAsync(app.ApplicationId, ModuleState.ModuleId);
_applications.Remove(app);
_confirmDeleteId = -1;
AddModuleMessage("Antrag erfolgreich gelöscht.", MessageType.Success);
}
catch (Exception ex)
{
AddModuleMessage("Fehler beim Löschen: " + ex.Message, MessageType.Error);
_confirmDeleteId = -1;
}
}
private string GetUserName(int userId)
{
return _userNames.TryGetValue(userId, out var name) ? name : $"Benutzer {userId}";
}
}

View File

@@ -22,6 +22,16 @@
<div class="card p-3">
<p>Bitte laden Sie Ihren Ingenieur-Antrag als PDF-Datei hoch.</p>
<div class="mb-3">
<label for="title" class="form-label">Titel</label>
<input id="title" type="text" class="form-control" @bind="_existingApp.Title" maxlength="256"/>
</div>
<div class="mb-3">
<label for="description" class="form-label">Kurzbeschreibung</label>
<textarea id="description" class="form-control" rows="3" @bind="_existingApp.ShortDescription" placeholder="Kurze Beschreibung Ihres Ingenieur-Antrags..."></textarea>
</div>
@* <div class="mb-3">
<label for="pdfUpload" class="form-label">Antrags-PDF</label>
<InputFile OnChange="@LoadFiles" class="form-control" accept=".pdf"/>
@@ -49,10 +59,33 @@ else
<p>
<strong>Status:</strong> <span class="badge bg-success">Veröffentlicht</span>
</p>
@if (!string.IsNullOrEmpty(_existingApp.Title))
{
<p>
<strong>Titel:</strong> @_existingApp.Title
</p>
}
@if (!string.IsNullOrEmpty(_existingApp.ShortDescription))
{
<p>
<strong>Kurzbeschreibung:</strong> @_existingApp.ShortDescription
</p>
}
<p>
<strong>Datum:</strong> @_existingApp.CreatedOn.ToShortDateString()
</p>
<button class="btn btn-primary" @onclick="EditApp">Antrag aktualisieren</button>
<div class="d-flex gap-2">
<button class="btn btn-primary" @onclick="EditApp">Antrag aktualisieren</button>
@if (!_confirmDelete)
{
<button class="btn btn-outline-danger" @onclick="() => _confirmDelete = true">Antrag löschen</button>
}
else
{
<button class="btn btn-danger" @onclick="DeleteApp">Wirklich löschen?</button>
<button class="btn btn-secondary" @onclick="() => _confirmDelete = false">Abbrechen</button>
}
</div>
</div>
</div>
}
@@ -63,6 +96,7 @@ else
private EngineerApplication _existingApp = new EngineerApplication();
private bool _showForm = true;
private bool _confirmDelete = false;
private string _message = "";
protected override async Task OnInitializedAsync()
@@ -104,8 +138,10 @@ else
{
ApplicationId = _existingApp?.ApplicationId ?? 0,
ModuleId = ModuleState.ModuleId,
UserId = PageState.User.UserId, // Ensure UserID is set
UserId = PageState.User.UserId,
FileId = _existingApp.FileId,
Title = _existingApp.Title,
ShortDescription = _existingApp.ShortDescription,
Status = "Published", // Auto-publish
SubmittedOn = DateTime.UtcNow,
ApprovedOn = DateTime.UtcNow, // Auto-approved
@@ -136,6 +172,23 @@ else
NavManager.NavigateTo(NavigateUrl());
}
private async Task DeleteApp()
{
try
{
await ApplicationService.DeleteApplicationAsync(_existingApp.ApplicationId, ModuleState.ModuleId);
_existingApp = new EngineerApplication { Status = "New" };
_showForm = true;
_confirmDelete = false;
_message = "Antrag erfolgreich gelöscht.";
}
catch (Exception ex)
{
_message = "Fehler beim Löschen: " + ex.Message;
_confirmDelete = false;
}
}
private Task OnSelectFile(int fileId)
{

View File

@@ -9,10 +9,12 @@ namespace SZUAbsolventenverein.Module.PremiumArea
public ModuleDefinition ModuleDefinition => new ModuleDefinition
{
Name = "PremiumArea",
Description = "This module adds a premium member system to Octane. Users receive premium status after completing a payment. Premium members get access to exclusive features and content.",
Version = "1.0.2",
ServerManagerType = "SZUAbsolventenverein.Module.PremiumArea.Manager.PremiumAreaManager, SZUAbsolventenverein.Module.PremiumArea.Server.Oqtane",
ReleaseVersions = "1.0.0,1.0.1,1.0.2",
Description =
"This module adds a premium member system to Octane. Users receive premium status after completing a payment. Premium members get access to exclusive features and content.",
Version = "1.0.4",
ServerManagerType =
"SZUAbsolventenverein.Module.PremiumArea.Manager.PremiumAreaManager, SZUAbsolventenverein.Module.PremiumArea.Server.Oqtane",
ReleaseVersions = "1.0.0,1.0.1,1.0.2,1.0.3,1.0.4",
Dependencies = "SZUAbsolventenverein.Module.PremiumArea.Shared.Oqtane",
PackageName = "SZUAbsolventenverein.Module.PremiumArea",
// Hier definieren Sie, WELCHE Permissions verfügbar sind

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd">
<metadata>
<id>$projectname$</id>
<version>1.0.0</version>
<id>$ProjectName$</id>
<version>1.0.4</version>
<authors>SZUAbsolventenverein</authors>
<owners>SZUAbsolventenverein</owners>
<title>PremiumArea</title>

View File

@@ -1,7 +1,7 @@
TargetFramework=$1
ProjectName=$2
find . -name "*.nupkg" -delete
"..\..\oqtane.framework\oqtane.package\FixProps.exe"
"..\..\oqtane.framework\oqtane.package\nuget.exe" pack %ProjectName%.nuspec -Properties targetframework=%TargetFramework%;projectname=%ProjectName%
cp -f "*.nupkg" "..\..\oqtane.framework\Oqtane.Server\Packages\"
find . -name *.nupkg -delete
dotnet run --project ../../fixProps/FixProps/FixProps.csproj
dotnet pack $ProjectName.nuspec "/p:targetframework=${TargetFramework};ProjectName=${ProjectName}"
cp -f *.nupkg ../../oqtane.framework/Oqtane.Server/Packages/

View File

@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Oqtane.Databases.Interfaces;
using Oqtane.Migrations;
using SZUAbsolventenverein.Module.PremiumArea.Migrations.EntityBuilders;
using SZUAbsolventenverein.Module.PremiumArea.Repository;
namespace SZUAbsolventenverein.Module.PremiumArea.Migrations
{
[DbContext(typeof(PremiumAreaContext))]
[Migration("SZUAbsolventenverein.Module.PremiumArea.01.00.00.03")]
public class AddTitleAndDescription : MultiDatabaseMigration
{
public AddTitleAndDescription(IDatabase database) : base(database)
{
}
protected override void Up(MigrationBuilder migrationBuilder)
{
var table = new EngineerApplicationEntityBuilder(migrationBuilder, ActiveDatabase);
table.AddStringColumn("Title", 256, true);
table.AddMaxStringColumn("ShortDescription", true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
var table = new EngineerApplicationEntityBuilder(migrationBuilder, ActiveDatabase);
table.DropColumn("Title");
table.DropColumn("ShortDescription");
}
}
}

View File

@@ -11,10 +11,16 @@ namespace SZUAbsolventenverein.Module.PremiumArea.Migrations.EntityBuilders
public class EngineerApplicationEntityBuilder : AuditableBaseEntityBuilder<EngineerApplicationEntityBuilder>
{
private const string _entityTableName = "SZUAbsolventenvereinEngineerApplications";
private readonly PrimaryKey<EngineerApplicationEntityBuilder> _primaryKey = new("PK_SZUAbsolventenvereinEngineerApplications", x => x.ApplicationId);
private readonly ForeignKey<EngineerApplicationEntityBuilder> _moduleForeignKey = new("FK_SZUAbsolventenvereinEngineerApplications_Module", x => x.ModuleId, "Module", "ModuleId", ReferentialAction.Cascade);
public EngineerApplicationEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database)
private readonly PrimaryKey<EngineerApplicationEntityBuilder> _primaryKey =
new("PK_SZUAbsolventenvereinEngineerApplications", x => x.ApplicationId);
private readonly ForeignKey<EngineerApplicationEntityBuilder> _moduleForeignKey =
new("FK_SZUAbsolventenvereinEngineerApplications_Module", x => x.ModuleId, "Module", "ModuleId",
ReferentialAction.Cascade);
public EngineerApplicationEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(
migrationBuilder, database)
{
EntityTableName = _entityTableName;
PrimaryKey = _primaryKey;
@@ -27,6 +33,8 @@ namespace SZUAbsolventenverein.Module.PremiumArea.Migrations.EntityBuilders
UserId = AddIntegerColumn(table, "UserId");
ModuleId = AddIntegerColumn(table, "ModuleId");
FileId = AddIntegerColumn(table, "FileId", true);
Title = AddStringColumn(table, "Title", 256, true);
ShortDescription = AddMaxStringColumn(table, "ShortDescription", true);
PdfFileName = AddStringColumn(table, "PdfFileName", 256);
Status = AddStringColumn(table, "Status", 50);
AdminReviewedBy = AddIntegerColumn(table, "AdminReviewedBy", true);
@@ -42,11 +50,12 @@ namespace SZUAbsolventenverein.Module.PremiumArea.Migrations.EntityBuilders
}
public OperationBuilder<AddColumnOperation> ApplicationId { get; set; }
public OperationBuilder<AddColumnOperation> UserId { get; set; }
public OperationBuilder<AddColumnOperation> ModuleId { get; set; }
public OperationBuilder<AddColumnOperation> FileId { get; set; }
public OperationBuilder<AddColumnOperation> Title { get; set; }
public OperationBuilder<AddColumnOperation> ShortDescription { get; set; }
public OperationBuilder<AddColumnOperation> PdfFileName { get; set; }
public OperationBuilder<AddColumnOperation> Status { get; set; }
public OperationBuilder<AddColumnOperation> AdminReviewedBy { get; set; }

View File

@@ -90,6 +90,8 @@ namespace SZUAbsolventenverein.Module.PremiumArea.Repository
if (existing != null)
{
existing.FileId = EngineerApplication.FileId;
existing.Title = EngineerApplication.Title;
existing.ShortDescription = EngineerApplication.ShortDescription;
existing.Status = EngineerApplication.Status;
existing.SubmittedOn = EngineerApplication.SubmittedOn;
existing.ApprovedOn = EngineerApplication.ApprovedOn;

View File

@@ -43,20 +43,28 @@ namespace SZUAbsolventenverein.Module.PremiumArea.Services
return Task.FromResult(_repository.GetEngineerApplications(ModuleId).ToList());
}
// Check if Premium
if (IsUserPremium(user))
var userId = _accessor.HttpContext.GetUserId();
var results = new List<EngineerApplication>();
// Always include the user's own applications (needed for Apply.razor)
if (userId != -1)
{
// Return Approved AND Published
// Repository GetEngineerApplications(ModuleId, status) usually filters by status.
// If I want both, I might need 2 calls or update Repository.
// Simple approach: Get All and filter here? No, repository likely filters.
// Let's call twice and combine for now, or assume Repository supports "Published".
var approved = _repository.GetEngineerApplications(ModuleId, "Approved");
var published = _repository.GetEngineerApplications(ModuleId, "Published");
return Task.FromResult(approved.Union(published).ToList());
var ownApps = _repository.GetEngineerApplications(ModuleId)
.Where(a => a.UserId == userId).ToList();
results.AddRange(ownApps);
}
return Task.FromResult(new List<EngineerApplication>());
// Check if Premium - also show approved/published apps from others
if (IsUserPremium(user))
{
var approved = _repository.GetEngineerApplications(ModuleId, "Approved");
var published = _repository.GetEngineerApplications(ModuleId, "Published");
results.AddRange(approved.Union(published));
// Remove duplicates (own apps might already be in approved/published)
results = results.GroupBy(a => a.ApplicationId).Select(g => g.First()).ToList();
}
return Task.FromResult(results);
}
public Task<List<EngineerApplication>> GetApplicationsAsync(int ModuleId, string status)
@@ -142,19 +150,23 @@ namespace SZUAbsolventenverein.Module.PremiumArea.Services
var userId = _accessor.HttpContext.GetUserId();
bool isAdmin = _userPermissions.IsAuthorized(user, _alias.SiteId, EntityNames.Module, Application.ModuleId,
PermissionNames.Edit);
bool isOwner = (userId != -1 && existing.UserId == userId);
if (isAdmin || (existing.UserId == userId && existing.Status == "Draft")) // Only owner can edit if Draft
if (isAdmin || isOwner)
{
if (!isAdmin)
{
Application.Status = existing.Status == "Draft" && Application.Status == "Submitted"
? "Published"
: existing.Status; // Auto-publish on submit
// If client sends "Published" (which Apply.razor does), apply it.
if (Application.Status == "Published" && existing.Status == "Draft")
// Owner can update their own application
// Accept "Published" status from client (auto-publish without admin approval)
if (Application.Status == "Published")
{
Application.SubmittedOn = DateTime.UtcNow;
Application.ApprovedOn = DateTime.UtcNow;
Application.SubmittedOn ??= DateTime.UtcNow;
Application.ApprovedOn ??= DateTime.UtcNow;
}
else
{
// Keep existing status if client didn't explicitly set to Published
Application.Status = existing.Status;
}
}
@@ -164,6 +176,9 @@ namespace SZUAbsolventenverein.Module.PremiumArea.Services
return Task.FromResult(Application);
}
_logger.Log(LogLevel.Error, this, LogFunction.Security,
"Unauthorized Update Attempt. UserId: {UserId}, AppOwner: {OwnerId}, IsAdmin: {IsAdmin}",
userId, existing.UserId, isAdmin);
return Task.FromResult<EngineerApplication>(null);
}
@@ -265,40 +280,20 @@ namespace SZUAbsolventenverein.Module.PremiumArea.Services
private bool IsUserPremium(System.Security.Claims.ClaimsPrincipal user)
{
// Oqtane's GetUserId() extension returns -1 if not found.
// We need to parse User object myself or use "User.Identity.Name" to find user if needed.
// But _premiumService.IsPremium(userId) asks for Int.
// I'll assume I can get UserId.
// Since Oqtane is weird with Claims sometimes, I'll use the Helper Extension `GetUserId()`.
// But wait, "GetUserId" is an extension on HttpContext or ClaimsPrincipal?
// In Oqtane.Shared.UserSecurity class? Or Oqtane.Extensions?
// Usually `_accessor.HttpContext.GlobalUserId()` ??
// Let's rely on standard DI/Context.
if (!user.Identity.IsAuthenticated)
return false;
// NOTE: IsUserPremium check requires querying the DB via PremiumService.
// This could be expensive on every list call. Caching might be better but for now DB is fine.
// To get UserId from ClaimsPrincipal:
// var userId = int.Parse(user.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier).Value);
// I will use `_accessor.HttpContext.GetUserId()` from `Oqtane.Infrastructure` or similar if available.
// Since I am already using `_accessor`, I'll rely on Oqtane extensions being available.
// Check 1: Oqtane role "Premium Member" (matches the UI-level check in ApplicationList.razor)
if (user.IsInRole("Premium Member"))
return true;
// Check 2: Custom UserPremium DB table (for premium granted via GrantPremium/payment)
int userId = -1;
// Trying standard Oqtane way which is usually:
if (user.Identity.IsAuthenticated)
var claim = user.Claims.FirstOrDefault(item =>
item.Type == System.Security.Claims.ClaimTypes.NameIdentifier);
if (claim != null)
{
// Using Oqtane.Shared.PrincipalExtensions?
// Let's just try to parse NameIdentifier if GetUserId() is not available in my context.
// But wait, `ModuleControllerBase` has `User` property.
// Here I am in a Service.
// I will write a helper to safely get UserId.
var claim = user.Claims.FirstOrDefault(item =>
item.Type == System.Security.Claims.ClaimTypes.NameIdentifier);
if (claim != null)
{
int.TryParse(claim.Value, out userId);
}
int.TryParse(claim.Value, out userId);
}
if (userId != -1)

View File

@@ -12,16 +12,8 @@ namespace SZUAbsolventenverein.Module.PremiumArea.Models
public int UserId { get; set; }
public int ModuleId { get; set; } // Context context
public int FileId { get; set; }
public string PdfFileName { get; set; } = "antrag.pdf"; // Legacy-Spalte, DB ist NOT NULL
// Legacy-Spalten: existieren noch in der DB (Migration lief nicht)
public bool IsReported { get; set; } = false;
public string ReportReason { get; set; }
public int ReportCount { get; set; } = 0;
public int? AdminReviewedBy { get; set; }
public DateTime? AdminReviewedAt { get; set; }
public string AdminNote { get; set; } = ""; // DB ist NOT NULL
[StringLength(256)] public string Title { get; set; }
public string ShortDescription { get; set; }
// Status: "Draft", "Submitted", "Approved", "Rejected"
[StringLength(50)] public string Status { get; set; }