培养方案:专业/年级版本、课程模块、学分校验、复制版本、发布锁定、旧版本归档。
教学任务:学期开课、授课教师、唯一主讲、合班、容量与周次校验、发布及结课。 SQLite 开发库已自动升级,无需删除原数据库。 新增对应 MySQL EF Core 迁移。 Vue 已编译进 wwwroot,仍可只运行 ASP.NET Core 单服务。 桌面端及 390px 窄屏页面验证通过,无前端控制台错误。
This commit is contained in:
@@ -0,0 +1,518 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Roles = ReadRoles)]
|
||||
[Route("api/curriculum-plans")]
|
||||
public sealed class CurriculumPlansController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
private const string ReadRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<object>>> GetPlans(
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
string? keyword = null,
|
||||
Guid? majorId = null,
|
||||
int? effectiveGrade = null,
|
||||
CurriculumPlanStatus? status = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||
var source = ScopedPlans().AsNoTracking();
|
||||
if (majorId.HasValue) source = source.Where(x => x.MajorId == majorId);
|
||||
if (effectiveGrade.HasValue)
|
||||
source = source.Where(x => x.EffectiveGrade == effectiveGrade);
|
||||
if (status.HasValue) source = source.Where(x => x.Status == status);
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
keyword = keyword.Trim();
|
||||
source = source.Where(x =>
|
||||
x.Name.Contains(keyword) ||
|
||||
x.Version.Contains(keyword) ||
|
||||
x.Major!.Name.Contains(keyword));
|
||||
}
|
||||
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var items = await source
|
||||
.OrderByDescending(x => x.EffectiveGrade)
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.Version,
|
||||
x.MajorId,
|
||||
MajorName = x.Major!.Name,
|
||||
CollegeId = x.Major.CollegeId,
|
||||
CollegeName = x.Major.College!.Name,
|
||||
x.EffectiveGrade,
|
||||
x.TotalCredits,
|
||||
x.Status,
|
||||
x.PublishedAt,
|
||||
ModuleCount = x.Modules.Count,
|
||||
CourseCount = x.Modules.SelectMany(module => module.Courses).Count(),
|
||||
x.UpdatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult> GetPlan(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await ScopedPlans()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.Id == id)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.Version,
|
||||
x.MajorId,
|
||||
MajorName = x.Major!.Name,
|
||||
SchoolingYears = x.Major.SchoolingYears,
|
||||
CollegeId = x.Major.CollegeId,
|
||||
CollegeName = x.Major.College!.Name,
|
||||
x.EffectiveGrade,
|
||||
x.TotalCredits,
|
||||
x.Status,
|
||||
x.Description,
|
||||
x.PublishedAt,
|
||||
x.CreatedAt,
|
||||
x.UpdatedAt,
|
||||
Modules = x.Modules
|
||||
.OrderBy(module => module.SortOrder)
|
||||
.ThenBy(module => module.Code)
|
||||
.Select(module => new
|
||||
{
|
||||
module.Id,
|
||||
module.Code,
|
||||
module.Name,
|
||||
module.RequiredCredits,
|
||||
module.SortOrder,
|
||||
AssignedCredits = module.Courses.Sum(course => course.Course!.Credits),
|
||||
Courses = module.Courses
|
||||
.OrderBy(course => course.RecommendedSemester)
|
||||
.ThenBy(course => course.Course!.Code)
|
||||
.Select(course => new
|
||||
{
|
||||
course.Id,
|
||||
course.CourseId,
|
||||
CourseCode = course.Course!.Code,
|
||||
CourseName = course.Course.Name,
|
||||
course.Course.Credits,
|
||||
course.Course.TotalHours,
|
||||
course.Course.Nature,
|
||||
course.RecommendedSemester,
|
||||
course.Type,
|
||||
course.Notes
|
||||
})
|
||||
})
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return plan is null ? NotFound() : Ok(plan);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult> Create(
|
||||
CurriculumPlanRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var major = await FindAccessibleMajorAsync(request.MajorId, cancellationToken);
|
||||
if (major is null) return ValidationProblem("所选专业不存在或不在当前数据范围内。");
|
||||
|
||||
var plan = new CurriculumPlan
|
||||
{
|
||||
MajorId = request.MajorId,
|
||||
Name = request.Name.Trim(),
|
||||
Version = request.Version.Trim(),
|
||||
EffectiveGrade = request.EffectiveGrade,
|
||||
TotalCredits = request.TotalCredits,
|
||||
Description = Normalize(request.Description)
|
||||
};
|
||||
db.CurriculumPlans.Add(plan);
|
||||
return await SaveCreatedAsync(plan.Id, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<ActionResult> Update(
|
||||
Guid id,
|
||||
CurriculumPlanRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await EditablePlanAsync(id, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != CurriculumPlanStatus.Draft)
|
||||
return ConflictProblem("已发布或已归档的培养方案不可直接修改,请复制为新版本。");
|
||||
var major = await FindAccessibleMajorAsync(request.MajorId, cancellationToken);
|
||||
if (major is null) return ValidationProblem("所选专业不存在或不在当前数据范围内。");
|
||||
|
||||
plan.MajorId = request.MajorId;
|
||||
plan.Name = request.Name.Trim();
|
||||
plan.Version = request.Version.Trim();
|
||||
plan.EffectiveGrade = request.EffectiveGrade;
|
||||
plan.TotalCredits = request.TotalCredits;
|
||||
plan.Description = Normalize(request.Description);
|
||||
return await SaveNoContentAsync(cancellationToken);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await EditablePlanAsync(id, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != CurriculumPlanStatus.Draft)
|
||||
return ConflictProblem("仅草稿方案可以删除。");
|
||||
db.CurriculumPlans.Remove(plan);
|
||||
return await SaveNoContentAsync(cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/clone")]
|
||||
public async Task<ActionResult> Clone(
|
||||
Guid id,
|
||||
CloneCurriculumPlanRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = await ScopedPlans()
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Modules)
|
||||
.ThenInclude(x => x.Courses)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (source is null) return NotFound();
|
||||
|
||||
var copy = new CurriculumPlan
|
||||
{
|
||||
MajorId = source.MajorId,
|
||||
Name = request.Name.Trim(),
|
||||
Version = request.Version.Trim(),
|
||||
EffectiveGrade = request.EffectiveGrade,
|
||||
TotalCredits = source.TotalCredits,
|
||||
Description = source.Description,
|
||||
Modules = source.Modules.Select(module => new CurriculumModule
|
||||
{
|
||||
Code = module.Code,
|
||||
Name = module.Name,
|
||||
RequiredCredits = module.RequiredCredits,
|
||||
SortOrder = module.SortOrder,
|
||||
Courses = module.Courses.Select(course => new CurriculumCourse
|
||||
{
|
||||
CourseId = course.CourseId,
|
||||
RecommendedSemester = course.RecommendedSemester,
|
||||
Type = course.Type,
|
||||
Notes = course.Notes
|
||||
}).ToList()
|
||||
}).ToList()
|
||||
};
|
||||
db.CurriculumPlans.Add(copy);
|
||||
return await SaveCreatedAsync(copy.Id, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/publish")]
|
||||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await ScopedPlans()
|
||||
.Include(x => x.Major)
|
||||
.Include(x => x.Modules)
|
||||
.ThenInclude(x => x.Courses)
|
||||
.ThenInclude(x => x.Course)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != CurriculumPlanStatus.Draft)
|
||||
return ConflictProblem("只有草稿方案可以发布。");
|
||||
if (plan.Modules.Count == 0 || plan.Modules.Any(x => x.Courses.Count == 0))
|
||||
return ConflictProblem("发布前每个课程模块都必须配置课程。");
|
||||
if (plan.Modules.Sum(x => x.RequiredCredits) != plan.TotalCredits)
|
||||
return ConflictProblem("各模块最低学分之和必须等于方案总学分。");
|
||||
if (plan.Modules.Any(module =>
|
||||
module.Courses.Sum(x => x.Course!.Credits) < module.RequiredCredits))
|
||||
return ConflictProblem("存在课程学分合计低于最低学分要求的模块。");
|
||||
if (plan.Modules.SelectMany(x => x.Courses).GroupBy(x => x.CourseId).Any(x => x.Count() > 1))
|
||||
return ConflictProblem("同一门课程不能重复加入多个模块。");
|
||||
if (plan.Modules.SelectMany(x => x.Courses).Any(x =>
|
||||
x.RecommendedSemester > plan.Major!.SchoolingYears * 2))
|
||||
return ConflictProblem("建议学期超出了该专业学制。");
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
var previousPlans = await ScopedPlans()
|
||||
.Where(x =>
|
||||
x.Id != plan.Id &&
|
||||
x.MajorId == plan.MajorId &&
|
||||
x.EffectiveGrade == plan.EffectiveGrade &&
|
||||
x.Status == CurriculumPlanStatus.Published)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var previous in previousPlans)
|
||||
{
|
||||
previous.Status = CurriculumPlanStatus.Archived;
|
||||
}
|
||||
|
||||
plan.Status = CurriculumPlanStatus.Published;
|
||||
plan.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{planId:guid}/modules")]
|
||||
public async Task<ActionResult> CreateModule(
|
||||
Guid planId,
|
||||
CurriculumModuleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await DraftPlanAsync(planId, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
var module = new CurriculumModule
|
||||
{
|
||||
CurriculumPlanId = plan.Id,
|
||||
Code = request.Code.Trim(),
|
||||
Name = request.Name.Trim(),
|
||||
RequiredCredits = request.RequiredCredits,
|
||||
SortOrder = request.SortOrder
|
||||
};
|
||||
db.CurriculumModules.Add(module);
|
||||
return await SaveCreatedAsync(module.Id, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("{planId:guid}/modules/{moduleId:guid}")]
|
||||
public async Task<ActionResult> UpdateModule(
|
||||
Guid planId,
|
||||
Guid moduleId,
|
||||
CurriculumModuleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound();
|
||||
var module = await db.CurriculumModules
|
||||
.FirstOrDefaultAsync(x => x.Id == moduleId && x.CurriculumPlanId == planId, cancellationToken);
|
||||
if (module is null) return NotFound();
|
||||
module.Code = request.Code.Trim();
|
||||
module.Name = request.Name.Trim();
|
||||
module.RequiredCredits = request.RequiredCredits;
|
||||
module.SortOrder = request.SortOrder;
|
||||
return await SaveNoContentAsync(cancellationToken);
|
||||
}
|
||||
|
||||
[HttpDelete("{planId:guid}/modules/{moduleId:guid}")]
|
||||
public async Task<ActionResult> DeleteModule(
|
||||
Guid planId,
|
||||
Guid moduleId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound();
|
||||
var module = await db.CurriculumModules
|
||||
.FirstOrDefaultAsync(x => x.Id == moduleId && x.CurriculumPlanId == planId, cancellationToken);
|
||||
if (module is null) return NotFound();
|
||||
db.CurriculumModules.Remove(module);
|
||||
return await SaveNoContentAsync(cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("{planId:guid}/modules/{moduleId:guid}/courses")]
|
||||
public async Task<ActionResult> AddCourse(
|
||||
Guid planId,
|
||||
Guid moduleId,
|
||||
CurriculumCourseRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await DraftPlanAsync(planId, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (!await db.CurriculumModules.AnyAsync(
|
||||
x => x.Id == moduleId && x.CurriculumPlanId == planId,
|
||||
cancellationToken))
|
||||
return NotFound();
|
||||
if (!await db.Courses.AnyAsync(
|
||||
x => x.Id == request.CourseId && x.IsEnabled,
|
||||
cancellationToken))
|
||||
return ValidationProblem("所选课程不存在或已停用。");
|
||||
if (request.RecommendedSemester > plan.Major!.SchoolingYears * 2)
|
||||
return ValidationProblem("建议学期超出了该专业学制。");
|
||||
if (await db.CurriculumCourses.AnyAsync(
|
||||
x => x.CurriculumModule!.CurriculumPlanId == planId &&
|
||||
x.CourseId == request.CourseId,
|
||||
cancellationToken))
|
||||
return ConflictProblem("该课程已存在于本方案中。");
|
||||
|
||||
var item = new CurriculumCourse
|
||||
{
|
||||
CurriculumModuleId = moduleId,
|
||||
CourseId = request.CourseId,
|
||||
RecommendedSemester = request.RecommendedSemester,
|
||||
Type = request.Type,
|
||||
Notes = Normalize(request.Notes)
|
||||
};
|
||||
db.CurriculumCourses.Add(item);
|
||||
return await SaveCreatedAsync(item.Id, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("{planId:guid}/modules/{moduleId:guid}/courses/{itemId:guid}")]
|
||||
public async Task<ActionResult> UpdateCourse(
|
||||
Guid planId,
|
||||
Guid moduleId,
|
||||
Guid itemId,
|
||||
CurriculumCourseRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await DraftPlanAsync(planId, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
var item = await db.CurriculumCourses
|
||||
.FirstOrDefaultAsync(x =>
|
||||
x.Id == itemId &&
|
||||
x.CurriculumModuleId == moduleId &&
|
||||
x.CurriculumModule!.CurriculumPlanId == planId,
|
||||
cancellationToken);
|
||||
if (item is null) return NotFound();
|
||||
if (!await db.Courses.AnyAsync(
|
||||
x => x.Id == request.CourseId && x.IsEnabled,
|
||||
cancellationToken))
|
||||
return ValidationProblem("所选课程不存在或已停用。");
|
||||
if (request.RecommendedSemester > plan.Major!.SchoolingYears * 2)
|
||||
return ValidationProblem("建议学期超出了该专业学制。");
|
||||
if (await db.CurriculumCourses.AnyAsync(
|
||||
x => x.Id != itemId &&
|
||||
x.CurriculumModule!.CurriculumPlanId == planId &&
|
||||
x.CourseId == request.CourseId,
|
||||
cancellationToken))
|
||||
return ConflictProblem("该课程已存在于本方案中。");
|
||||
|
||||
item.CourseId = request.CourseId;
|
||||
item.RecommendedSemester = request.RecommendedSemester;
|
||||
item.Type = request.Type;
|
||||
item.Notes = Normalize(request.Notes);
|
||||
return await SaveNoContentAsync(cancellationToken);
|
||||
}
|
||||
|
||||
[HttpDelete("{planId:guid}/modules/{moduleId:guid}/courses/{itemId:guid}")]
|
||||
public async Task<ActionResult> DeleteCourse(
|
||||
Guid planId,
|
||||
Guid moduleId,
|
||||
Guid itemId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound();
|
||||
var item = await db.CurriculumCourses
|
||||
.FirstOrDefaultAsync(x =>
|
||||
x.Id == itemId &&
|
||||
x.CurriculumModuleId == moduleId &&
|
||||
x.CurriculumModule!.CurriculumPlanId == planId,
|
||||
cancellationToken);
|
||||
if (item is null) return NotFound();
|
||||
db.CurriculumCourses.Remove(item);
|
||||
return await SaveNoContentAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private IQueryable<CurriculumPlan> ScopedPlans()
|
||||
{
|
||||
var source = db.CurriculumPlans.AsQueryable();
|
||||
var collegeId = ScopedCollegeId();
|
||||
return collegeId.HasValue
|
||||
? source.Where(x => x.Major!.CollegeId == collegeId.Value)
|
||||
: source;
|
||||
}
|
||||
|
||||
private async Task<Major?> FindAccessibleMajorAsync(Guid majorId, CancellationToken cancellationToken)
|
||||
{
|
||||
var source = db.Majors.AsQueryable();
|
||||
var collegeId = ScopedCollegeId();
|
||||
if (collegeId.HasValue) source = source.Where(x => x.CollegeId == collegeId.Value);
|
||||
return await source.FirstOrDefaultAsync(x => x.Id == majorId, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<CurriculumPlan?> EditablePlanAsync(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
await ScopedPlans().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
|
||||
private async Task<CurriculumPlan?> DraftPlanAsync(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
await ScopedPlans()
|
||||
.Include(x => x.Major)
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == id && x.Status == CurriculumPlanStatus.Draft,
|
||||
cancellationToken);
|
||||
|
||||
private Guid? ScopedCollegeId()
|
||||
{
|
||||
if (!User.IsInRole(SystemRoles.CollegeAdmin)) return null;
|
||||
return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId)
|
||||
? collegeId
|
||||
: Guid.Empty;
|
||||
}
|
||||
|
||||
private async Task<ActionResult> SaveCreatedAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Created(string.Empty, new { id });
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
return ConflictProblem("版本、模块编码或课程配置重复。");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ActionResult> SaveNoContentAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
return ConflictProblem("数据重复,或记录已被其他业务引用。");
|
||||
}
|
||||
}
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "无法完成操作",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
public sealed record CurriculumPlanRequest(
|
||||
Guid MajorId,
|
||||
[Required, MaxLength(120)] string Name,
|
||||
[Required, MaxLength(30)] string Version,
|
||||
[Range(2000, 2200)] int EffectiveGrade,
|
||||
[Range(typeof(decimal), "0.1", "999")] decimal TotalCredits,
|
||||
[MaxLength(1000)] string? Description);
|
||||
|
||||
public sealed record CloneCurriculumPlanRequest(
|
||||
[Required, MaxLength(120)] string Name,
|
||||
[Required, MaxLength(30)] string Version,
|
||||
[Range(2000, 2200)] int EffectiveGrade);
|
||||
|
||||
public sealed record CurriculumModuleRequest(
|
||||
[Required, MaxLength(30)] string Code,
|
||||
[Required, MaxLength(100)] string Name,
|
||||
[Range(typeof(decimal), "0", "999")] decimal RequiredCredits,
|
||||
[Range(0, 9999)] int SortOrder);
|
||||
|
||||
public sealed record CurriculumCourseRequest(
|
||||
Guid CourseId,
|
||||
[Range(1, 20)] int RecommendedSemester,
|
||||
CurriculumCourseType Type,
|
||||
[MaxLength(500)] string? Notes);
|
||||
Reference in New Issue
Block a user