Implemented Article Domain Model

This commit is contained in:
Mia Rose Winter 2024-01-15 20:47:10 +01:00
parent 0f006a7159
commit c96b6c5ba2
Signed by: miawinter
GPG key ID: 4B6F6A83178F595E
12 changed files with 660 additions and 1 deletions

View file

@ -0,0 +1,43 @@
@page "/article/{id:guid}"
@using Microsoft.EntityFrameworkCore
@using Wave.Data
@inject IDbContextFactory<ApplicationDbContext> ContextFactory;
<PageTitle>Wave - @Article.Title</PageTitle>
<h1 class="text-3xl lg:text-5xl font-light mb-6">@Article.Title</h1>
<p class="text-sm mb-3">By @Article.Author.Name</p>
@if (Article.Reviewer is not null && Article.Reviewer.Id != Article.Author.Id) {
<p class="text-sm mb-3">Reviewed by @Article.Reviewer.Name</p>
}
<div class="prose">
@Content
</div>
@code {
[Parameter]
public Guid Id { get; set; }
private Article Article { get; set; } = null!;
private MarkupString Content => new(Article.BodyHtml);
protected override async Task OnInitializedAsync() {
// We need blocking calls here, bc otherwise Blazor will execute Render in parallel,
// running into a null pointer on the Article property and panicking
// ReSharper disable once MethodHasAsyncOverload
await using var context = ContextFactory.CreateDbContext();
// ReSharper disable once MethodHasAsyncOverload
var now = DateTimeOffset.UtcNow;
Article = context.Set<Article>()
.Include(a => a.Author)
.Include(a => a.Reviewer)
.Where(a => a.Status >= ArticleStatus.Published && a.PublishDate <= now)
.First(a => a.Id == Id);
if (Article is null) throw new ApplicationException("Article not found.");
}
}

View file

@ -1,6 +1,11 @@
@page "/"
@using Microsoft.Extensions.Localization
@using Microsoft.EntityFrameworkCore
@using Wave.Data
@rendermode InteractiveServer
@attribute [StreamRendering]
@inject IDbContextFactory<ApplicationDbContext> ContextFactory;
@inject IStringLocalizer<Home> Localizer
<PageTitle>Home</PageTitle>
@ -8,3 +13,29 @@
<h1>@Localizer["Greeting"]</h1>
Welcome to your new app.
<div>
@foreach (Article article in Articles) {
<div>
<a href="/article/@article.Id">
<p>@article.Title</p>
<p>By @article.Author.Name</p>
</a>
</div>
}
</div>
@code {
private List<Article> Articles { get; } = [];
protected override async Task OnInitializedAsync() {
await using var context = await ContextFactory.CreateDbContextAsync();
var articles = await context.Set<Article>()
.Include(a => a.Author)
.OrderBy(a => a.PublishDate)
.Take(10)
.ToListAsync();
Articles.AddRange(articles);
}
}

View file

@ -20,5 +20,23 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
pfp.Property(p => p.ImageId).IsRequired();
pfp.ToTable("ProfilePictures");
});
builder.Entity<Article>(article => {
article.HasKey(a => a.Id);
article.Property(a => a.Title)
.IsRequired().HasMaxLength(256);
article.HasOne(a => a.Author).WithMany()
.IsRequired().OnDelete(DeleteBehavior.Cascade);
article.HasOne(a => a.Reviewer).WithMany()
.IsRequired(false).OnDelete(DeleteBehavior.SetNull);
article.Property(a => a.CreationDate)
.IsRequired().HasDefaultValueSql("now()");
article.Property(a => a.LastModified)
.IsRequired().HasDefaultValueSql("now()");
article.HasQueryFilter(a => !a.IsDeleted);
});
}
}

View file

@ -4,4 +4,6 @@ namespace Wave.Data;
public class ApplicationUser : IdentityUser {
public ProfilePicture? ProfilePicture { get; set; }
public string Name => UserName ?? Email ?? Id;
}

32
Wave/Data/Article.cs Normal file
View file

@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
namespace Wave.Data;
public enum ArticleStatus {
Draft = 0,
InReview = 1,
Published = 2
}
// TODO:: Add category / tags for MVP
// TODO:: Archive System (Notice / Redirect to new content?) (Deprecation date?)
// TODO:: Reference used files?
public class Article : ISoftDelete {
[Key]
public Guid Id { get; set; }
public bool IsDeleted { get; set; }
[MaxLength(256)]
public required string Title { get; set; }
public required string Body { get; set; }
public string BodyHtml { get; set; } = string.Empty;
public required ApplicationUser Author { get; set; }
public ApplicationUser? Reviewer { get; set; }
public ArticleStatus Status { get; set; }
public DateTimeOffset CreationDate { get; set; } = DateTimeOffset.Now;
public DateTimeOffset PublishDate { get; set; } = DateTimeOffset.MaxValue;
public DateTimeOffset? LastModified { get; set; }
}

5
Wave/Data/ISoftDelete.cs Normal file
View file

@ -0,0 +1,5 @@
namespace Wave.Data;
public interface ISoftDelete {
bool IsDeleted { get; set; }
}

View file

@ -0,0 +1,385 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Wave.Data;
#nullable disable
namespace Wave.Data.Migrations.postgres
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240115181000_Articles")]
partial class Articles
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Wave.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Wave.Data.Article", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AuthorId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BodyHtml")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreationDate")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastModified")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset>("PublishDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReviewerId")
.HasColumnType("text");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("ReviewerId");
b.ToTable("Article");
});
modelBuilder.Entity("Wave.Data.ProfilePicture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ApplicationUserId")
.HasColumnType("text");
b.Property<Guid>("ImageId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("ProfilePictures", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Wave.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Wave.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Wave.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Wave.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Wave.Data.Article", b =>
{
b.HasOne("Wave.Data.ApplicationUser", "Author")
.WithMany()
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Wave.Data.ApplicationUser", "Reviewer")
.WithMany()
.HasForeignKey("ReviewerId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Author");
b.Navigation("Reviewer");
});
modelBuilder.Entity("Wave.Data.ProfilePicture", b =>
{
b.HasOne("Wave.Data.ApplicationUser", null)
.WithOne("ProfilePicture")
.HasForeignKey("Wave.Data.ProfilePicture", "ApplicationUserId")
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("Wave.Data.ApplicationUser", b =>
{
b.Navigation("ProfilePicture");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,63 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Wave.Data.Migrations.postgres;
// Add-Migration Articles -OutputDir Data/Migrations/postgres -Project Wave -StartupProject Wave -Context ApplicationDbContext -Args "ConnectionStrings:DefaultConnection=Host=localhost;Port=5432;AllowAnonymousConnections=true"
/// <inheritdoc />
public partial class Articles : Migration {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) {
migrationBuilder.CreateTable(
name: "Article",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Body = table.Column<string>(type: "text", nullable: false),
BodyHtml = table.Column<string>(type: "text", nullable: false),
AuthorId = table.Column<string>(type: "text", nullable: false),
ReviewerId = table.Column<string>(type: "text", nullable: true),
Status = table.Column<int>(type: "integer", nullable: false),
CreationDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false,
defaultValueSql: "now()"),
PublishDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
LastModified = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false,
defaultValueSql: "now()")
},
constraints: table => {
table.PrimaryKey("PK_Article", x => x.Id);
table.ForeignKey(
name: "FK_Article_AspNetUsers_AuthorId",
column: x => x.AuthorId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Article_AspNetUsers_ReviewerId",
column: x => x.ReviewerId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_Article_AuthorId",
table: "Article",
column: "AuthorId");
migrationBuilder.CreateIndex(
name: "IX_Article_ReviewerId",
table: "Article",
column: "ReviewerId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) {
migrationBuilder.DropTable(
name: "Article");
}
}

View file

@ -218,6 +218,61 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Wave.Data.Article", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AuthorId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BodyHtml")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreationDate")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastModified")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset>("PublishDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReviewerId")
.HasColumnType("text");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("ReviewerId");
b.ToTable("Article");
});
modelBuilder.Entity("Wave.Data.ProfilePicture", b =>
{
b.Property<int>("Id")
@ -291,6 +346,24 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.IsRequired();
});
modelBuilder.Entity("Wave.Data.Article", b =>
{
b.HasOne("Wave.Data.ApplicationUser", "Author")
.WithMany()
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Wave.Data.ApplicationUser", "Reviewer")
.WithMany()
.HasForeignKey("ReviewerId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Author");
b.Navigation("Reviewer");
});
modelBuilder.Entity("Wave.Data.ProfilePicture", b =>
{
b.HasOne("Wave.Data.ApplicationUser", null)

View file

@ -84,4 +84,10 @@
.AddSupportedCultures(cultures)
.AddSupportedUICultures(cultures));
{
using var scope = app.Services.CreateScope();
using var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
context.Database.Migrate();
}
app.Run();

View file

@ -11,6 +11,7 @@
<ItemGroup>
<PackageReference Include="Magick.NET-Q8-AnyCPU" Version="13.5.0" />
<PackageReference Include="Markdig" Version="0.34.0" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />

File diff suppressed because one or more lines are too long