commit 1f92f82b2c379cb334aaa132a1ca1b0d257b63dd Author: snow flurry Date: Tue Oct 17 21:55:10 2023 -0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a72f3dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,454 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# JetBrains Rider +.idea/ +*.sln.iml + +## +## Visual Studio Code +## +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json diff --git a/PhoneToolMX.Models/AlwaysIncludeAttribute.cs b/PhoneToolMX.Models/AlwaysIncludeAttribute.cs new file mode 100644 index 0000000..57a3123 --- /dev/null +++ b/PhoneToolMX.Models/AlwaysIncludeAttribute.cs @@ -0,0 +1,7 @@ +namespace PhoneToolMX.Models +{ + [System.AttributeUsage(AttributeTargets.Property)] + public class AlwaysIncludeAttribute : System.Attribute + { + } +} diff --git a/PhoneToolMX.Models/CheckExtensionsAttribute.cs b/PhoneToolMX.Models/CheckExtensionsAttribute.cs new file mode 100644 index 0000000..49dd61a --- /dev/null +++ b/PhoneToolMX.Models/CheckExtensionsAttribute.cs @@ -0,0 +1,25 @@ +using PhoneToolMX.Models.ViewModels; +using System.ComponentModel.DataAnnotations; +using System.Data; + +namespace PhoneToolMX.Models +{ + public class CheckExtensionsAttribute : ValidationAttribute + { + private int? MaxExtensions; + + public override string FormatErrorMessage(string name) + { + return MaxExtensions != null + ? $"Up to {MaxExtensions} extensions can be assigned to this phone" + : "Too many extensions assigned to this phone"; + } + public override bool IsValid(object value) + { + if (value is not PhoneVM phone) throw new ConstraintException($"{GetType().Name} is only allowed for PhoneVM"); + if (phone.MaxExtensions <= 0 || phone.Extensions == null) return true; + MaxExtensions = phone.MaxExtensions; + return !(phone.Extensions.Count > phone.MaxExtensions); + } + } +} diff --git a/PhoneToolMX.Models/Data/PTMXContext.cs b/PhoneToolMX.Models/Data/PTMXContext.cs new file mode 100644 index 0000000..0db822b --- /dev/null +++ b/PhoneToolMX.Models/Data/PTMXContext.cs @@ -0,0 +1,126 @@ +// using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using System.Security.Claims; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using PhoneToolMX.Models; +using System.Net.NetworkInformation; + +// ReSharper disable CheckNamespace +// ReSharper disable UnusedAutoPropertyAccessor.Global + +namespace PhoneToolMX.Data { + public class PTMXContext : DbContext + { + public DbSet Phones { get; set; } + public DbSet PhoneModels { get; set; } + public DbSet Extensions { get; set; } + public DbSet CustomData { get; set; } + public DbSet Users { get; set; } + + public PTMXContext(DbContextOptions options) : base(options) + { + } + + #region Public helpers + + /// + /// Adds an entity to the database, marking the given as its owner. + /// + /// The that owns this entity. + /// The entity to be created. + /// A model conforming to . + /// The entity entry for the created entity. + public async Task> AddOwnable(User owner, TEntity entity) where TEntity: OwnedBase + { + var set = Set(); + entity.Owners ??= new List(); + entity.Owners.Add(owner); + var entry = await AddAsync(entity); + return entry; + } + + /// + /// Gets all entities of a certain model owned by the . + /// + /// The object to be considered the owner. + /// A model conforming to + /// All entities of the model owned by the given user + public ICollection GetOwned(User owner) where TEntity : class, IOwnedModel + { + if (owner == null) return null; + var entity = Set().Where(x => x.Owners.Any(o => o.Id == owner.Id)); + // eager load all w/ AlwaysInclude + entity = typeof(TEntity).GetProperties() + .Where(p => p.GetCustomAttributes(typeof(AlwaysIncludeAttribute), true) + .Length != 0) + .Aggregate(entity, (current, prop) => current.Include(prop.Name)); + return entity.ToList(); + } + + /// + /// Gets a model entity by its Id + /// + /// Id of the model, or null if you're like that + /// A model confirming to that has a given DbSet + /// The model defined by the Id, or null if none exist. + /// If null is provided as the id, null will be returned. id is nullable solely for compatibility. + /// + public TEntity GetEntityById(int? id) where TEntity : class, IModel + { + return id == null ? null : Set().FirstOrDefault(o => o.Id == id); + } + + #endregion + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Core of PTMX: Phones and extensions + var ext = modelBuilder.Entity(); + ext.HasKey(x => x.Id); + ext.Property(x => x.Id).UseIdentityColumn(); + ext.Property(x => x.ExtId).HasComputedColumnSql("\"Id\" + 1000", stored: true); + + var phone = modelBuilder.Entity(); + phone + .HasMany(p => p.Extensions) + .WithMany(x => x.Phones); + phone.HasKey(p => p.Id); + phone.Property(p => p.Id).UseIdentityColumn(); + // Randomly generates a 24-char password + phone + .Property(p => p.Password) + .HasDefaultValueSql("encode(gen_random_bytes(18), 'base64')"); + + // Wallpapers, ringtones, etc + var cd = modelBuilder.Entity(); + cd.HasKey(c => c.Id); + cd.Property(c => c.Id).UseIdentityColumn(); + + // Phone models, for custom tests + var pm = modelBuilder.Entity(); + pm.HasKey(p => p.Id); + pm.Property(p => p.Id).UseIdentityColumn(); + pm.HasData(new PhoneModel + { + Id = 0, + ModelName = "Polycom VVX300/310", + MaxExtensions = 6, + PreVvxPolycom = false, + }); + + // Authz/RBAC + var userEnt = modelBuilder.Entity(); + userEnt + .HasMany(u => u.Phones) + .WithMany(p => p.Owners); + userEnt + .HasMany(u => u.Extensions) + .WithMany(x => x.Owners); + userEnt + .HasKey(u => u.Id); + + modelBuilder.Entity() + .HasKey(r => r.Id); + } + } +} \ No newline at end of file diff --git a/PhoneToolMX.Models/HeaderAttribute.cs b/PhoneToolMX.Models/HeaderAttribute.cs new file mode 100644 index 0000000..34a34ab --- /dev/null +++ b/PhoneToolMX.Models/HeaderAttribute.cs @@ -0,0 +1,16 @@ +namespace PhoneToolMX.Models +{ + [System.AttributeUsage(AttributeTargets.Property)] + public class HeaderAttribute : System.Attribute + { + public string Title { get; set; } + public bool Primary { get; set; } + public bool Small { get; set; } + public HeaderAttribute(string title) + { + Title = title; + } + + public HeaderAttribute() {} + } +} diff --git a/PhoneToolMX.Models/Migrations/20231015015926_InitialCreate.Designer.cs b/PhoneToolMX.Models/Migrations/20231015015926_InitialCreate.Designer.cs new file mode 100644 index 0000000..499cd23 --- /dev/null +++ b/PhoneToolMX.Models/Migrations/20231015015926_InitialCreate.Designer.cs @@ -0,0 +1,358 @@ +// +using System; +using System.Net.NetworkInformation; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PhoneToolMX.Data; + +#nullable disable + +namespace PhoneToolMX.Models.Migrations +{ + [DbContext(typeof(PTMXContext))] + [Migration("20231015015926_InitialCreate")] + partial class InitialCreate + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.16") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ExtensionPhone", b => + { + b.Property("ExtensionsId") + .HasColumnType("integer"); + + b.Property("PhonesId") + .HasColumnType("integer"); + + b.HasKey("ExtensionsId", "PhonesId"); + + b.HasIndex("PhonesId"); + + b.ToTable("ExtensionPhone"); + }); + + modelBuilder.Entity("ExtensionUser", b => + { + b.Property("ExtensionsId") + .HasColumnType("integer"); + + b.Property("OwnersId") + .HasColumnType("text"); + + b.HasKey("ExtensionsId", "OwnersId"); + + b.HasIndex("OwnersId"); + + b.ToTable("ExtensionUser"); + }); + + modelBuilder.Entity("PhoneToolMX.Models.CustomData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Data") + .HasColumnType("bytea"); + + b.Property("DataType") + .HasColumnType("integer"); + + b.Property("FriendlyName") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("PhoneId") + .HasColumnType("integer"); + + b.Property("Size") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PhoneId"); + + b.ToTable("CustomData"); + }); + + modelBuilder.Entity("PhoneToolMX.Models.Extension", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("integer") + .HasComputedColumnSql("\"Id\" + 1000", true); + + b.Property("HoldMusicId") + .HasColumnType("integer"); + + b.Property("Listed") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("HoldMusicId"); + + b.ToTable("Extensions"); + }); + + modelBuilder.Entity("PhoneToolMX.Models.Phone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BackgroundId") + .HasColumnType("integer"); + + b.Property("FriendlyName") + .IsRequired() + .HasColumnType("text"); + + b.Property("MacAddress") + .IsRequired() + .HasColumnType("macaddr"); + + b.Property("ModelId") + .HasColumnType("integer"); + + b.Property("Password") + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValueSql("encode(gen_random_bytes(18), 'base64')"); + + b.HasKey("Id"); + + b.HasIndex("BackgroundId"); + + b.HasIndex("ModelId"); + + b.ToTable("Phones"); + }); + + modelBuilder.Entity("PhoneToolMX.Models.PhoneModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("MaxExtensions") + .HasColumnType("bigint"); + + b.Property("ModelName") + .HasColumnType("text"); + + b.Property("PreVvxPolycom") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("PhoneModels"); + + b.HasData( + new + { + Id = 0, + MaxExtensions = 6L, + ModelName = "Polycom VVX300/310", + PreVvxPolycom = false + }); + }); + + modelBuilder.Entity("PhoneToolMX.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("NormalizedName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Role"); + }); + + modelBuilder.Entity("PhoneToolMX.Models.User", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .HasColumnType("text"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("PhoneUser", b => + { + b.Property("OwnersId") + .HasColumnType("text"); + + b.Property("PhonesId") + .HasColumnType("integer"); + + b.HasKey("OwnersId", "PhonesId"); + + b.HasIndex("PhonesId"); + + b.ToTable("PhoneUser"); + }); + + modelBuilder.Entity("ExtensionPhone", b => + { + b.HasOne("PhoneToolMX.Models.Extension", null) + .WithMany() + .HasForeignKey("ExtensionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PhoneToolMX.Models.Phone", null) + .WithMany() + .HasForeignKey("PhonesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExtensionUser", b => + { + b.HasOne("PhoneToolMX.Models.Extension", null) + .WithMany() + .HasForeignKey("ExtensionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PhoneToolMX.Models.User", null) + .WithMany() + .HasForeignKey("OwnersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PhoneToolMX.Models.CustomData", b => + { + b.HasOne("PhoneToolMX.Models.Phone", null) + .WithMany("Ringtones") + .HasForeignKey("PhoneId"); + }); + + modelBuilder.Entity("PhoneToolMX.Models.Extension", b => + { + b.HasOne("PhoneToolMX.Models.CustomData", "HoldMusic") + .WithMany() + .HasForeignKey("HoldMusicId"); + + b.Navigation("HoldMusic"); + }); + + modelBuilder.Entity("PhoneToolMX.Models.Phone", b => + { + b.HasOne("PhoneToolMX.Models.CustomData", "Background") + .WithMany() + .HasForeignKey("BackgroundId"); + + b.HasOne("PhoneToolMX.Models.PhoneModel", "Model") + .WithMany() + .HasForeignKey("ModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Background"); + + b.Navigation("Model"); + }); + + modelBuilder.Entity("PhoneUser", b => + { + b.HasOne("PhoneToolMX.Models.User", null) + .WithMany() + .HasForeignKey("OwnersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PhoneToolMX.Models.Phone", null) + .WithMany() + .HasForeignKey("PhonesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PhoneToolMX.Models.Phone", b => + { + b.Navigation("Ringtones"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PhoneToolMX.Models/Migrations/20231015015926_InitialCreate.cs b/PhoneToolMX.Models/Migrations/20231015015926_InitialCreate.cs new file mode 100644 index 0000000..a7727eb --- /dev/null +++ b/PhoneToolMX.Models/Migrations/20231015015926_InitialCreate.cs @@ -0,0 +1,288 @@ +using System; +using System.Net.NetworkInformation; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace PhoneToolMX.Models.Migrations +{ + public partial class InitialCreate : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PhoneModels", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ModelName = table.Column(type: "text", nullable: true), + MaxExtensions = table.Column(type: "bigint", nullable: false), + PreVvxPolycom = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PhoneModels", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Role", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: true), + NormalizedName = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Role", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + UserName = table.Column(type: "text", nullable: true), + NormalizedUserName = table.Column(type: "text", nullable: true), + Email = table.Column(type: "text", nullable: true), + NormalizedEmail = table.Column(type: "text", nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CustomData", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FriendlyName = table.Column(type: "character varying(16)", maxLength: 16, nullable: true), + DataType = table.Column(type: "integer", nullable: false), + Size = table.Column(type: "bigint", nullable: false), + Data = table.Column(type: "bytea", nullable: true), + PhoneId = table.Column(type: "integer", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CustomData", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Extensions", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ExtId = table.Column(type: "integer", nullable: false, computedColumnSql: "\"Id\" + 1000", stored: true), + DirectoryName = table.Column(type: "text", nullable: false), + Listed = table.Column(type: "boolean", nullable: false), + HoldMusicId = table.Column(type: "integer", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Extensions", x => x.Id); + table.ForeignKey( + name: "FK_Extensions_CustomData_HoldMusicId", + column: x => x.HoldMusicId, + principalTable: "CustomData", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "Phones", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MacAddress = table.Column(type: "macaddr", nullable: false), + FriendlyName = table.Column(type: "text", nullable: false), + ModelId = table.Column(type: "integer", nullable: false), + BackgroundId = table.Column(type: "integer", nullable: true), + Password = table.Column(type: "text", nullable: true, defaultValueSql: "encode(gen_random_bytes(18), 'base64')") + }, + constraints: table => + { + table.PrimaryKey("PK_Phones", x => x.Id); + table.ForeignKey( + name: "FK_Phones_CustomData_BackgroundId", + column: x => x.BackgroundId, + principalTable: "CustomData", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_Phones_PhoneModels_ModelId", + column: x => x.ModelId, + principalTable: "PhoneModels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ExtensionUser", + columns: table => new + { + ExtensionsId = table.Column(type: "integer", nullable: false), + OwnersId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExtensionUser", x => new { x.ExtensionsId, x.OwnersId }); + table.ForeignKey( + name: "FK_ExtensionUser_Extensions_ExtensionsId", + column: x => x.ExtensionsId, + principalTable: "Extensions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ExtensionUser_Users_OwnersId", + column: x => x.OwnersId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ExtensionPhone", + columns: table => new + { + ExtensionsId = table.Column(type: "integer", nullable: false), + PhonesId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExtensionPhone", x => new { x.ExtensionsId, x.PhonesId }); + table.ForeignKey( + name: "FK_ExtensionPhone_Extensions_ExtensionsId", + column: x => x.ExtensionsId, + principalTable: "Extensions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ExtensionPhone_Phones_PhonesId", + column: x => x.PhonesId, + principalTable: "Phones", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PhoneUser", + columns: table => new + { + OwnersId = table.Column(type: "text", nullable: false), + PhonesId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PhoneUser", x => new { x.OwnersId, x.PhonesId }); + table.ForeignKey( + name: "FK_PhoneUser_Phones_PhonesId", + column: x => x.PhonesId, + principalTable: "Phones", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PhoneUser_Users_OwnersId", + column: x => x.OwnersId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "PhoneModels", + columns: new[] { "Id", "MaxExtensions", "ModelName", "PreVvxPolycom" }, + values: new object[] { 0, 6L, "Polycom VVX300/310", false }); + + migrationBuilder.CreateIndex( + name: "IX_CustomData_PhoneId", + table: "CustomData", + column: "PhoneId"); + + migrationBuilder.CreateIndex( + name: "IX_ExtensionPhone_PhonesId", + table: "ExtensionPhone", + column: "PhonesId"); + + migrationBuilder.CreateIndex( + name: "IX_Extensions_HoldMusicId", + table: "Extensions", + column: "HoldMusicId"); + + migrationBuilder.CreateIndex( + name: "IX_ExtensionUser_OwnersId", + table: "ExtensionUser", + column: "OwnersId"); + + migrationBuilder.CreateIndex( + name: "IX_Phones_BackgroundId", + table: "Phones", + column: "BackgroundId"); + + migrationBuilder.CreateIndex( + name: "IX_Phones_ModelId", + table: "Phones", + column: "ModelId"); + + migrationBuilder.CreateIndex( + name: "IX_PhoneUser_PhonesId", + table: "PhoneUser", + column: "PhonesId"); + + migrationBuilder.AddForeignKey( + name: "FK_CustomData_Phones_PhoneId", + table: "CustomData", + column: "PhoneId", + principalTable: "Phones", + principalColumn: "Id"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_CustomData_Phones_PhoneId", + table: "CustomData"); + + migrationBuilder.DropTable( + name: "ExtensionPhone"); + + migrationBuilder.DropTable( + name: "ExtensionUser"); + + migrationBuilder.DropTable( + name: "PhoneUser"); + + migrationBuilder.DropTable( + name: "Role"); + + migrationBuilder.DropTable( + name: "Extensions"); + + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.DropTable( + name: "Phones"); + + migrationBuilder.DropTable( + name: "CustomData"); + + migrationBuilder.DropTable( + name: "PhoneModels"); + } + } +} diff --git a/PhoneToolMX.Models/Migrations/PTMXContextModelSnapshot.cs b/PhoneToolMX.Models/Migrations/PTMXContextModelSnapshot.cs new file mode 100644 index 0000000..5bcb161 --- /dev/null +++ b/PhoneToolMX.Models/Migrations/PTMXContextModelSnapshot.cs @@ -0,0 +1,356 @@ +// +using System; +using System.Net.NetworkInformation; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PhoneToolMX.Data; + +#nullable disable + +namespace PhoneToolMX.Models.Migrations +{ + [DbContext(typeof(PTMXContext))] + partial class PTMXContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.16") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ExtensionPhone", b => + { + b.Property("ExtensionsId") + .HasColumnType("integer"); + + b.Property("PhonesId") + .HasColumnType("integer"); + + b.HasKey("ExtensionsId", "PhonesId"); + + b.HasIndex("PhonesId"); + + b.ToTable("ExtensionPhone", (string)null); + }); + + modelBuilder.Entity("ExtensionUser", b => + { + b.Property("ExtensionsId") + .HasColumnType("integer"); + + b.Property("OwnersId") + .HasColumnType("text"); + + b.HasKey("ExtensionsId", "OwnersId"); + + b.HasIndex("OwnersId"); + + b.ToTable("ExtensionUser", (string)null); + }); + + modelBuilder.Entity("PhoneToolMX.Models.CustomData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Data") + .HasColumnType("bytea"); + + b.Property("DataType") + .HasColumnType("integer"); + + b.Property("FriendlyName") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("PhoneId") + .HasColumnType("integer"); + + b.Property("Size") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PhoneId"); + + b.ToTable("CustomData", (string)null); + }); + + modelBuilder.Entity("PhoneToolMX.Models.Extension", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("integer") + .HasComputedColumnSql("\"Id\" + 1000", true); + + b.Property("HoldMusicId") + .HasColumnType("integer"); + + b.Property("Listed") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("HoldMusicId"); + + b.ToTable("Extensions", (string)null); + }); + + modelBuilder.Entity("PhoneToolMX.Models.Phone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BackgroundId") + .HasColumnType("integer"); + + b.Property("FriendlyName") + .IsRequired() + .HasColumnType("text"); + + b.Property("MacAddress") + .IsRequired() + .HasColumnType("macaddr"); + + b.Property("ModelId") + .HasColumnType("integer"); + + b.Property("Password") + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValueSql("encode(gen_random_bytes(18), 'base64')"); + + b.HasKey("Id"); + + b.HasIndex("BackgroundId"); + + b.HasIndex("ModelId"); + + b.ToTable("Phones", (string)null); + }); + + modelBuilder.Entity("PhoneToolMX.Models.PhoneModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("MaxExtensions") + .HasColumnType("bigint"); + + b.Property("ModelName") + .HasColumnType("text"); + + b.Property("PreVvxPolycom") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("PhoneModels", (string)null); + + b.HasData( + new + { + Id = 0, + MaxExtensions = 6L, + ModelName = "Polycom VVX300/310", + PreVvxPolycom = false + }); + }); + + modelBuilder.Entity("PhoneToolMX.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("NormalizedName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Role", (string)null); + }); + + modelBuilder.Entity("PhoneToolMX.Models.User", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .HasColumnType("text"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("PhoneUser", b => + { + b.Property("OwnersId") + .HasColumnType("text"); + + b.Property("PhonesId") + .HasColumnType("integer"); + + b.HasKey("OwnersId", "PhonesId"); + + b.HasIndex("PhonesId"); + + b.ToTable("PhoneUser", (string)null); + }); + + modelBuilder.Entity("ExtensionPhone", b => + { + b.HasOne("PhoneToolMX.Models.Extension", null) + .WithMany() + .HasForeignKey("ExtensionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PhoneToolMX.Models.Phone", null) + .WithMany() + .HasForeignKey("PhonesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExtensionUser", b => + { + b.HasOne("PhoneToolMX.Models.Extension", null) + .WithMany() + .HasForeignKey("ExtensionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PhoneToolMX.Models.User", null) + .WithMany() + .HasForeignKey("OwnersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PhoneToolMX.Models.CustomData", b => + { + b.HasOne("PhoneToolMX.Models.Phone", null) + .WithMany("Ringtones") + .HasForeignKey("PhoneId"); + }); + + modelBuilder.Entity("PhoneToolMX.Models.Extension", b => + { + b.HasOne("PhoneToolMX.Models.CustomData", "HoldMusic") + .WithMany() + .HasForeignKey("HoldMusicId"); + + b.Navigation("HoldMusic"); + }); + + modelBuilder.Entity("PhoneToolMX.Models.Phone", b => + { + b.HasOne("PhoneToolMX.Models.CustomData", "Background") + .WithMany() + .HasForeignKey("BackgroundId"); + + b.HasOne("PhoneToolMX.Models.PhoneModel", "Model") + .WithMany() + .HasForeignKey("ModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Background"); + + b.Navigation("Model"); + }); + + modelBuilder.Entity("PhoneUser", b => + { + b.HasOne("PhoneToolMX.Models.User", null) + .WithMany() + .HasForeignKey("OwnersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PhoneToolMX.Models.Phone", null) + .WithMany() + .HasForeignKey("PhonesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PhoneToolMX.Models.Phone", b => + { + b.Navigation("Ringtones"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PhoneToolMX.Models/Models/CustomData.cs b/PhoneToolMX.Models/Models/CustomData.cs new file mode 100644 index 0000000..0b48983 --- /dev/null +++ b/PhoneToolMX.Models/Models/CustomData.cs @@ -0,0 +1,21 @@ +using PhoneToolMX.Models; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PhoneToolMX.Models { + public enum CustomDataType { + Background, + MusicTone, + } + public class CustomData : IModel { + public int? Id { get; set; } + + [MaxLength(16)] + [Header("Name")] + public string FriendlyName { get; set; } + public CustomDataType DataType { get; set; } + [Header("Size")] + public uint Size { get; set; } + public byte[] Data { get; set; } + } +} \ No newline at end of file diff --git a/PhoneToolMX.Models/Models/ErrorViewModel.cs b/PhoneToolMX.Models/Models/ErrorViewModel.cs new file mode 100644 index 0000000..e7a1460 --- /dev/null +++ b/PhoneToolMX.Models/Models/ErrorViewModel.cs @@ -0,0 +1,8 @@ +namespace PhoneToolMX.Models; + +public class ErrorViewModel +{ + public string RequestId { get; set; } + + public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); +} diff --git a/PhoneToolMX.Models/Models/Extension.cs b/PhoneToolMX.Models/Models/Extension.cs new file mode 100644 index 0000000..822d042 --- /dev/null +++ b/PhoneToolMX.Models/Models/Extension.cs @@ -0,0 +1,25 @@ +using PhoneToolMX.Data; +using PhoneToolMX.Models; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PhoneToolMX.Models { + public class Extension: OwnedBase { + public ICollection Phones { get; set; } + + [Header("Ext.", Primary = true, Small = true)] + public int ExtId { get; set; } + + [Header("Directory Name")] + [Required] + public string DirectoryName { get; set; } + + [Header("Listed?", Small = true)] + public bool Listed { get; set; } + + public CustomData HoldMusic { get; set; } + + [NotMapped] + public string ListViewName => $"{DirectoryName} ({ExtId})"; + } +} \ No newline at end of file diff --git a/PhoneToolMX.Models/Models/IModel.cs b/PhoneToolMX.Models/Models/IModel.cs new file mode 100644 index 0000000..49335f8 --- /dev/null +++ b/PhoneToolMX.Models/Models/IModel.cs @@ -0,0 +1,19 @@ +using System.Reflection; + +namespace PhoneToolMX.Models +{ + public interface IModel + { + public int? Id { get; set; } + + public void Commit(IModel obj) + { + if (GetType() != obj.GetType()) throw new ArgumentException("Input object must be the same model"); + foreach (var prop in GetType().GetProperties().Where(p => p.CanWrite)) { + if (prop.GetValue(obj, null) is {} propVal) { + prop.SetValue(this, propVal, null); + } + } + } + } +} diff --git a/PhoneToolMX.Models/Models/IOwnedModel.cs b/PhoneToolMX.Models/Models/IOwnedModel.cs new file mode 100644 index 0000000..0a8ac81 --- /dev/null +++ b/PhoneToolMX.Models/Models/IOwnedModel.cs @@ -0,0 +1,10 @@ +using PhoneToolMX.Models.ViewModels; + +namespace PhoneToolMX.Models +{ + public interface IOwnedModel : IModel + { + public ICollection Owners { get; set; } + public bool IsOwnedBy(User user); + } +} diff --git a/PhoneToolMX.Models/Models/OwnedBase.cs b/PhoneToolMX.Models/Models/OwnedBase.cs new file mode 100644 index 0000000..3d2c86c --- /dev/null +++ b/PhoneToolMX.Models/Models/OwnedBase.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore.Metadata; +using PhoneToolMX.Models.ViewModels; +using System.ComponentModel.DataAnnotations; + +namespace PhoneToolMX.Models +{ + public class OwnedBase : IOwnedModel + { + public int? Id { get; set; } + public ICollection Owners { get; set; } + + public bool IsOwnedBy(User user) + { + return (Owners != null && Owners.Any(o => o.Id == user.Id)); + } + } +} diff --git a/PhoneToolMX.Models/Models/Phone.cs b/PhoneToolMX.Models/Models/Phone.cs new file mode 100644 index 0000000..abc5fad --- /dev/null +++ b/PhoneToolMX.Models/Models/Phone.cs @@ -0,0 +1,28 @@ +using PhoneToolMX.Data; +using PhoneToolMX.Models; +using PhoneToolMX.Models.ViewModels; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Net.NetworkInformation; +using System.Security.Cryptography; + +namespace PhoneToolMX.Models { + public class Phone: OwnedBase { + [Required] + public PhysicalAddress MacAddress { get; set; } + + [Header("Name", Primary = true)] + [Required] + public string FriendlyName { get; set; } + + [AlwaysInclude] + [Required] + public PhoneModel Model { get; set; } + + [AlwaysInclude] + public ICollection Extensions { get; set; } + public CustomData Background { get; set; } + public ICollection Ringtones { get; set; } + public string Password { get; set; } + } +} \ No newline at end of file diff --git a/PhoneToolMX.Models/Models/PhoneModel.cs b/PhoneToolMX.Models/Models/PhoneModel.cs new file mode 100644 index 0000000..1403dd0 --- /dev/null +++ b/PhoneToolMX.Models/Models/PhoneModel.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace PhoneToolMX.Models +{ + public class PhoneModel + { + public int? Id { get; set; } + + /// + /// The friendly name of the phone (e.g., "Polycom VVX300/310") + /// + public string ModelName { get; set; } + + /// + /// The maximum lines the phone can support. + /// + public uint MaxExtensions { get; set; } + + /// + /// This setting impacts whether the model is pre-VVX era. + /// All Polycom phones that can be provisioned by Polycom UC + /// before the VVX era (mainly, SPIP phones) have a limit of + /// 300KiB per ringtone. + /// + /// All Polycom phones, VVX or otherwise, have a limit of + /// 600KiB total across all ringtones. + /// + public bool PreVvxPolycom { get; set; } + } +} diff --git a/PhoneToolMX.Models/Models/Role.cs b/PhoneToolMX.Models/Models/Role.cs new file mode 100644 index 0000000..e257cc4 --- /dev/null +++ b/PhoneToolMX.Models/Models/Role.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Identity; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PhoneToolMX.Models +{ + public class Role : IdentityRole + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public override string Id { get; set; } + + public Role() { } + public Role(string name) + { + Name = name; + } + } +} diff --git a/PhoneToolMX.Models/Models/User.cs b/PhoneToolMX.Models/Models/User.cs new file mode 100644 index 0000000..f0cc500 --- /dev/null +++ b/PhoneToolMX.Models/Models/User.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Identity; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PhoneToolMX.Models { + public class User : IdentityUser + { + public ICollection Phones; + public ICollection Extensions; + } +} \ No newline at end of file diff --git a/PhoneToolMX.Models/PhoneToolMX.Models.csproj b/PhoneToolMX.Models/PhoneToolMX.Models.csproj new file mode 100644 index 0000000..62e3384 --- /dev/null +++ b/PhoneToolMX.Models/PhoneToolMX.Models.csproj @@ -0,0 +1,19 @@ + + + + net6.0 + enable + disable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/PhoneToolMX.Models/ViewModels/ExtensionVM.cs b/PhoneToolMX.Models/ViewModels/ExtensionVM.cs new file mode 100644 index 0000000..35067d9 --- /dev/null +++ b/PhoneToolMX.Models/ViewModels/ExtensionVM.cs @@ -0,0 +1,57 @@ +using PhoneToolMX.Data; +using System.ComponentModel.DataAnnotations; + +namespace PhoneToolMX.Models.ViewModels +{ + public class ExtensionVM: IViewModel + { + + public int? Id { get; set; } + + [Header("Ext.", Primary = true, Small = true)] + public int ExtId { get; set; } + + [Header("Directory Name")] + [Required] + [MaxLength(15)] + public string DirectoryName { get; set; } + + [Header("Listed?", Small = true)] + [Required] + public bool Listed { get; set; } + + public int? HoldMusic { get; set; } + + public IOwnedModel ToEntity(PTMXContext ctx) + { + return new Extension + { + Id = Id, + ExtId = ExtId, + DirectoryName = DirectoryName, + Listed = Listed, + HoldMusic = HoldMusic == null ? null : ctx.CustomData.FirstOrDefault(c => c.Id == HoldMusic), + Owners = new List(), + }; + } + public IOwnedModel ToEntity(PTMXContext ctx, IOwnedModel current) + { + var ent = ToEntity(ctx); + ent.Owners = current.Owners; + return ent; + } + + public IViewModel FromEntity(IOwnedModel entity) + { + if (entity is not Extension extEnt) throw new ArgumentException("entity must be of type Extension"); + return new ExtensionVM + { + Id = extEnt.Id, + ExtId = extEnt.ExtId, + DirectoryName = extEnt.DirectoryName, + Listed = extEnt.Listed, + HoldMusic = extEnt.HoldMusic?.Id, + }; + } + } +} diff --git a/PhoneToolMX.Models/ViewModels/IViewModel.cs b/PhoneToolMX.Models/ViewModels/IViewModel.cs new file mode 100644 index 0000000..0909369 --- /dev/null +++ b/PhoneToolMX.Models/ViewModels/IViewModel.cs @@ -0,0 +1,15 @@ +using PhoneToolMX.Data; +using PhoneToolMX.Models; + +namespace PhoneToolMX.Models.ViewModels +{ + public interface IViewModel + { + public int? Id { get; set; } + + public IOwnedModel ToEntity(PTMXContext ctx); + public IOwnedModel ToEntity(PTMXContext ctx, IOwnedModel current); + + public IViewModel FromEntity(IOwnedModel entity); + } +} diff --git a/PhoneToolMX.Models/ViewModels/PhoneVM.cs b/PhoneToolMX.Models/ViewModels/PhoneVM.cs new file mode 100644 index 0000000..68db836 --- /dev/null +++ b/PhoneToolMX.Models/ViewModels/PhoneVM.cs @@ -0,0 +1,64 @@ +using PhoneToolMX.Data; +using PhoneToolMX.Models; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Net.NetworkInformation; + +namespace PhoneToolMX.Models.ViewModels +{ + [CheckExtensions] + public class PhoneVM : IViewModel + { + public int? Id { get; set; } + + [Header("MAC Address")] + [Required] + [RegularExpression("(?:[0-9a-fA-F]{2}[-:]?){6}", ErrorMessage = "Must be of the form XX:XX:XX:XX:XX:XX, XX-XX-XX-XX-XX-XX, or XXXXXXXXXXXX")] + public string MacAddress { get; set; } + + [Header("Name", Primary = true)] + [Required] + public string FriendlyName { get; set; } + + [Required] + public int? Model { get; set; } + + public ICollection Extensions { get; set; } + + public int MaxExtensions { get; set; } + + public IOwnedModel ToEntity(PTMXContext ctx) + { + return new Phone + { + Id = Id, + MacAddress = PhysicalAddress.Parse(MacAddress), + FriendlyName = FriendlyName, + Model = ctx.PhoneModels.FirstOrDefault(p => p.Id == Model)!, + Extensions = Extensions?.Select(x => ctx.Extensions.FirstOrDefault(e => e.Id == x)).ToList(), + Owners = new List(), + }; + } + + public IOwnedModel ToEntity(PTMXContext ctx, IOwnedModel current) + { + var ent = ToEntity(ctx); + ent.Owners = current.Owners; + return ent; + } + + public IViewModel FromEntity(IOwnedModel entity) + { + if (entity is not Phone phoneEnt) throw new ArgumentException("entity must be of type Phone"); + return new PhoneVM + { + Id = entity.Id, + MacAddress = phoneEnt.MacAddress.ToString(), + FriendlyName = phoneEnt.FriendlyName, + Model = phoneEnt.Model!.Id, + Extensions = phoneEnt.Extensions?.Select(x => x.Id).ToList(), + MaxExtensions = (int)phoneEnt.Model!.MaxExtensions, + }; + } + } +} diff --git a/PhoneToolMX/.vscode/launch.json b/PhoneToolMX/.vscode/launch.json new file mode 100644 index 0000000..517fc54 --- /dev/null +++ b/PhoneToolMX/.vscode/launch.json @@ -0,0 +1,36 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md + "name": ".NET Core Launch (web)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/bin/Debug/net6.0/PhoneToolMX.dll", + "args": [], + "cwd": "${workspaceFolder}", + "stopAtEntry": false, + // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://localhost:5001" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/PhoneToolMX/.vscode/tasks.json b/PhoneToolMX/.vscode/tasks.json new file mode 100644 index 0000000..d0b7e92 --- /dev/null +++ b/PhoneToolMX/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/PhoneToolMX.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/PhoneToolMX.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/PhoneToolMX.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/PhoneToolMX/Controllers/BaseController.cs b/PhoneToolMX/Controllers/BaseController.cs new file mode 100644 index 0000000..09555d7 --- /dev/null +++ b/PhoneToolMX/Controllers/BaseController.cs @@ -0,0 +1,225 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using PhoneToolMX.Data; +using PhoneToolMX.Helpers; +using PhoneToolMX.Models; +using PhoneToolMX.Models.ViewModels; +using System.Security.Authentication; +using System.Security.Claims; + +namespace PhoneToolMX.Controllers +{ + [Authorize] + [Route("[controller]")] + public abstract class BaseController : Controller + where T: OwnedBase, IModel + where TViewModel : IViewModel, new() + { + protected private readonly PTMXContext _context; + private readonly UserManager _userManager; + + protected BaseController(UserManager mgr, PTMXContext ctx) + { + _context = ctx; + _userManager = mgr; + } + + protected private virtual Task PreForm(TViewModel vm) + { + throw new NotImplementedException(); + } + + #region Helper methods + + /// + /// Gets the "friendly" name for an owned model object. + /// + /// The model object to inspect + /// The friendly name, or its ID if none was defined + private static string GetFriendlyName(IViewModel vm) + { + if (vm is not TViewModel model) return vm.Id.ToString(); + return typeof(TViewModel).GetProperties().Where(pi => + { + var headers = pi.GetCustomAttributes(typeof(HeaderAttribute), false); + return headers.Length != 0 && ((HeaderAttribute)headers[0]).Primary; + }).Select(pi => pi.GetValue(model)?.ToString()).FirstOrDefault() ?? model.Id.ToString(); + } + + private static string GetFriendlyName(IOwnedModel model) + { + var vm = new TViewModel().FromEntity(model); + return GetFriendlyName(vm); + } + + protected private async Task CurrentUser() + { + var claim = HttpContext.User.Claims.FirstOrDefault(c => c.Type.Equals(ClaimTypes.NameIdentifier)); + if (claim == null) { + throw new InvalidCredentialException("fuck this noise, nameid claim missing"); + } + var user = await _userManager.FindByIdAsync(claim.Value); + return user; + } + + private async Task FormView(TViewModel vm) + { + await PreForm(vm); + return View("_Form", vm); + } + + private void SetMessage(FormMessage msg) + { + TempData["Message"] = msg.ToString(); + } + + private void SetMessage(FormMessageType type, string msg) + { + SetMessage(new FormMessage + { + Type = type, + Message = msg, + }); + } + + private async Task ValidationError(TViewModel vm) + { + var plural = ModelState.ErrorCount > 1 ? "s" : string.Empty; + SetMessage( + FormMessageType.Error, + $"Error{plural} occurred in validation. Make the changes required and click Submit to try again." + ); + return await FormView(vm); + } + + #endregion + + #region Create + + [HttpGet("New")] + public async Task New() + { + ViewData["Title"] = $"Add new {typeof(T).Name.ToLower()}"; + return await FormView(default); + } + + [HttpPost("New")] + public async Task NewPost(TViewModel vm) + { + ViewData["Title"] = $"New {typeof(T).Name}"; + + if (!ModelState.IsValid) return await ValidationError(vm); + + if (vm.ToEntity(_context) is not T model) throw new InvalidOperationException($"{typeof(TViewModel).FullName}.ToEntity() somehow produced something other than a {typeof(T).FullName}"); + var entity = await _context.AddOwnable(await CurrentUser(), model); + await _context.SaveChangesAsync(); + + SetMessage( + FormMessageType.Success, + $"{typeof(T).Name} {GetFriendlyName(vm)} was created." + ); + + return RedirectToAction("Edit", new { id = entity.Entity.Id }); + } + + #endregion + + #region Read + + [HttpGet] + public async Task Index() + { + ViewData["Title"] = $"My {typeof(T).Name}s"; + return View("Index", _context + .GetOwned(await CurrentUser()) + .Select(m => (TViewModel)new TViewModel().FromEntity(m)) + .ToList()); + } + + #endregion + + #region Update + + [HttpGet("Edit")] + public async Task Edit(int id) + { + var model = _context.GetOwned(await CurrentUser()).FirstOrDefault(o => o.Id == id); + if (model == null) return NotFound(); + ViewData["Title"] = $"Editing {typeof(T).Name.ToLower()} {GetFriendlyName(new TViewModel().FromEntity(model))}"; + return await FormView((TViewModel)new TViewModel().FromEntity(model)); + } + + [HttpPost("Edit")] + public async Task EditPost(TViewModel vm) + { + if (vm?.Id == null) { + return BadRequest(); + } + + if (!ModelState.IsValid) return await ValidationError(vm); + + // merge VM's changes with DbModel + var currentModel = _context.GetEntityById(vm.Id); + if (vm.ToEntity(_context, currentModel) is not T entity) throw new InvalidOperationException($"{typeof(TViewModel).FullName}.ToEntity() somehow produced something other than a {typeof(T).FullName}"); + currentModel.Commit(entity); + + // and commit back + _context.Set().Update(currentModel); + await _context.SaveChangesAsync(); + + SetMessage( + FormMessageType.Success, + $"{typeof(T).Name} {GetFriendlyName(vm)} was updated." + ); + + return RedirectToAction("Edit", new { id = entity.Id }); + } + + #endregion + + #region Delete + + [HttpGet("Delete")] + public async Task Delete(int id) + { + var model = _context.GetEntityById(id); + + if (model?.IsOwnedBy(await CurrentUser()) != true) { + return NotFound(); + } + + ViewData["Title"] = $"Delete {GetFriendlyName(model)}"; + TempData["ModelDelete"] = model.Id; + return View("DeleteConfirm", model); + } + + [HttpPost("Delete")] + public async Task PostDelete(int id) + { + if ((int?)TempData["ModelDelete"] != id) { + return BadRequest(); + } + + var model = _context.GetEntityById(id); + + if (model?.IsOwnedBy(await CurrentUser()) != true) { + return NotFound(); + } + + _context.Set().Remove(model); + await _context.SaveChangesAsync(); + SetMessage( + FormMessageType.Success, + $"{typeof(T).Name} {GetFriendlyName(new TViewModel().FromEntity(model))} was successfully deleted." + ); + + return RedirectToAction("Index"); + } + + + + #endregion + + } +} diff --git a/PhoneToolMX/Controllers/ExtensionController.cs b/PhoneToolMX/Controllers/ExtensionController.cs new file mode 100644 index 0000000..16651dc --- /dev/null +++ b/PhoneToolMX/Controllers/ExtensionController.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using PhoneToolMX.Models; +using PhoneToolMX.Data; +using PhoneToolMX.Helpers; +using PhoneToolMX.Models.ViewModels; + +namespace PhoneToolMX.Controllers { + [Authorize] + public class ExtensionController : BaseController + { + public ExtensionController(UserManager mgr, PTMXContext ctx) : base(mgr, ctx) {} + + protected override private Task PreForm(ExtensionVM e) + { + var holdMusics = _context.CustomData.Where(d => d.DataType == CustomDataType.MusicTone).ToList(); + ViewBag.HoldMusics = holdMusics; + ViewBag.SelectedMusic = e?.HoldMusic == null ? null : holdMusics.FirstOrDefault(d => d.Id == e.HoldMusic); + return Task.CompletedTask; + } + } +} diff --git a/PhoneToolMX/Controllers/HomeController.cs b/PhoneToolMX/Controllers/HomeController.cs new file mode 100644 index 0000000..2e458cc --- /dev/null +++ b/PhoneToolMX/Controllers/HomeController.cs @@ -0,0 +1,27 @@ +using System.Diagnostics; +using System.Security.Claims; +using Microsoft.AspNetCore.Mvc; +using PhoneToolMX.Models; + +namespace PhoneToolMX.Controllers; + +public class HomeController : Controller +{ + private readonly ILogger _logger; + + public HomeController(ILogger logger) + { + _logger = logger; + } + + public IActionResult Index() + { + return View(); + } + + [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] + public IActionResult Error() + { + return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); + } +} diff --git a/PhoneToolMX/Controllers/ModelController.cs b/PhoneToolMX/Controllers/ModelController.cs new file mode 100644 index 0000000..3ec8879 --- /dev/null +++ b/PhoneToolMX/Controllers/ModelController.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; + +namespace PhoneToolMX.Controllers +{ + public class ModelController : Controller + { + // GET + public IActionResult Index() + { + return View(); + } + } +} diff --git a/PhoneToolMX/Controllers/PhoneController.cs b/PhoneToolMX/Controllers/PhoneController.cs new file mode 100644 index 0000000..70b0fd0 --- /dev/null +++ b/PhoneToolMX/Controllers/PhoneController.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using PhoneToolMX.Data; +using PhoneToolMX.Helpers; +using PhoneToolMX.Models; +using PhoneToolMX.Models.ViewModels; + +namespace PhoneToolMX.Controllers +{ + [Authorize] + public class PhoneController : BaseController + { + public PhoneController(UserManager mgr, PTMXContext ctx) : base(mgr, ctx) {} + + protected override private async Task PreForm(PhoneVM pvm) + { + var myExts = _context.GetOwned(await CurrentUser()); + var phoneModels = _context.PhoneModels.ToList(); + ViewBag.MyExtensions = myExts; + ViewBag.SelectedExtensions = pvm?.Extensions == null ? null : myExts.Where(x => pvm.Extensions.Contains(x.Id)).ToList(); + ViewBag.ModelNumbers = phoneModels; + ViewBag.CurrentModel = pvm?.Model == null ? null : phoneModels.Where(m => m.Id == pvm.Model); + } + } +} diff --git a/PhoneToolMX/Folder.DotSettings b/PhoneToolMX/Folder.DotSettings new file mode 100644 index 0000000..33461e4 --- /dev/null +++ b/PhoneToolMX/Folder.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/PhoneToolMX/Helpers/FormMessage.cs b/PhoneToolMX/Helpers/FormMessage.cs new file mode 100644 index 0000000..520ceac --- /dev/null +++ b/PhoneToolMX/Helpers/FormMessage.cs @@ -0,0 +1,18 @@ +using System.Text.Json; + +namespace PhoneToolMX.Helpers { + public enum FormMessageType { + Success, + Info, + Warning, + Error, + } + + public class FormMessage { + public FormMessageType Type { get; set; } + public string Message { get; set; } + + public string CssClassName() => $"msg-{Type.ToString().ToLower()}"; + public override string ToString() => JsonSerializer.Serialize(this); + } +} diff --git a/PhoneToolMX/Helpers/HtmlTable.cs b/PhoneToolMX/Helpers/HtmlTable.cs new file mode 100644 index 0000000..5297fe5 --- /dev/null +++ b/PhoneToolMX/Helpers/HtmlTable.cs @@ -0,0 +1,91 @@ +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using PhoneToolMX.Models; +using System.Linq.Expressions; +using Microsoft.AspNetCore.Mvc; + +namespace PhoneToolMX.Helpers +{ + public static class HtmlTable + { + /// + /// Creates an HTML table for a given collection of model objects. + /// + /// HtmlHelper instance in the view + /// List of table headers + /// Function used to determine the structure of each row. All objects will be converted to strings with ToString(). + /// + /// An HTML table representing the list. + /// + /// https://xkcd.com/1319/ + /// + public static IHtmlContent MakeTable(this IHtmlHelper> helper) + { + var builder = new HtmlContentBuilder(); + var urlFactory = helper.ViewContext.HttpContext.RequestServices.GetRequiredService(); + var url = urlFactory.GetUrlHelper(helper.ViewContext); + var props = typeof(TModel).GetProperties().Where(pi => pi.GetCustomAttributes(typeof(HeaderAttribute), false).Length != 0).ToList(); + var idProp = typeof(TModel).GetProperty("Id"); + var headerList = props.Select(pi => + { + var attr = (HeaderAttribute)pi.GetCustomAttributes(typeof(HeaderAttribute), false)[0]; + return attr; + }).ToList(); + headerList.Add(new HeaderAttribute + { + Title = "Actions", + Small = true, + }); + + builder.AppendHtmlLine("") + .AppendHtmlLine(""); + foreach (var header in headerList) { + builder.AppendFormat("{1}", + (header.Small) ? " width=2" : string.Empty, + header.Title); + } + builder.AppendHtmlLine("") + .AppendHtmlLine(""); + + // build body + if (helper.ViewData.Model is {} rows) { + foreach (var row in rows) { + var cells = props + .Select(pi => + { + var val = pi.GetValue(row, null); + if (val is bool someBool) { + // idk, yes/no feels better to me than true/false for user-facing stuff + return someBool ? "Yes" : "No"; + } + return val?.ToString() ?? "(null)"; + }) + .ToList(); + var cellId = idProp?.GetValue(row, null)?.ToString() ?? "INVALID_ID"; + builder.AppendHtmlLine(""); + foreach (var cell in cells) { + builder.AppendFormat("", cell); + } + // Actions cell + builder.AppendHtml("") + .AppendHtmlLine(""); + } + if (rows.Count == 0) { + builder.AppendFormat("", headerList.Count); + } + } else { + builder.AppendFormat("", headerList.Count); + } + + // close remaining table tags + return builder.AppendHtmlLine("") + .AppendHtmlLine("
{0}") + // ReSharper disable Mvc.ActionNotResolved + .AppendHtml($"\"Edit\"") + .AppendHtml($"\"Delete\"") + .AppendHtmlLine("
No entries found
Invalid model found
"); + } + } +} diff --git a/PhoneToolMX/PhoneToolMX.csproj b/PhoneToolMX/PhoneToolMX.csproj new file mode 100644 index 0000000..d509b90 --- /dev/null +++ b/PhoneToolMX/PhoneToolMX.csproj @@ -0,0 +1,35 @@ + + + + net6.0 + enable + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + <_ContentIncludedByDefault Remove="wwwroot\js\site.js" /> + + + diff --git a/PhoneToolMX/Program.cs b/PhoneToolMX/Program.cs new file mode 100644 index 0000000..db12cc8 --- /dev/null +++ b/PhoneToolMX/Program.cs @@ -0,0 +1,131 @@ +using PhoneToolMX.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using PhoneToolMX.Models; +using System.Security.Authentication; +using System.Security.Claims; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddControllersWithViews(); +builder.Services.AddDbContext( +options => options.UseNpgsql(builder.Configuration.GetConnectionString("DbConnection"), + b => b.MigrationsAssembly("PhoneToolMX.Models"))); +builder.Services.AddDatabaseDeveloperPageExceptionFilter(); + +builder.Services.AddIdentityCore(opts => + { + opts.ClaimsIdentity.UserIdClaimType = "sub"; + opts.ClaimsIdentity.UserNameClaimType = "preferred_username"; + opts.ClaimsIdentity.EmailClaimType = "email"; + }) + .AddRoles() + .AddRoleManager>() + .AddSignInManager>() + .AddUserManager>() + .AddEntityFrameworkStores(); + +// Using OIDC +builder.Services.AddAuthentication(opts => +{ + opts.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + opts.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; +}) + .AddCookie() + .AddOpenIdConnect((OpenIdConnectOptions opts) => { + // pull from appsettings + var oidcConfig = builder.Configuration.GetRequiredSection("OpenIdConnect"); + opts.ClientId = oidcConfig.GetValue("ClientId"); + opts.ClientSecret = oidcConfig.GetValue("ClientSecret"); + opts.MetadataAddress = oidcConfig.GetValue("MetadataUrl"); + opts.Authority = oidcConfig.GetValue("Authority"); + + opts.GetClaimsFromUserInfoEndpoint = true; + opts.SaveTokens = false; + opts.ResponseType = "code"; + opts.Scope.Add("openid"); + + opts.Events = new OpenIdConnectEvents + { + OnUserInformationReceived = async ctx => + { + // Create the user in UserManager for future authz + var _userManager = ctx.HttpContext.RequestServices.GetRequiredService>(); + var curUser = ctx.Principal; + var user = new User + { + Id = curUser?.Claims.FirstOrDefault(c => c.Type.Equals(ClaimTypes.NameIdentifier)) + ?.Value, + UserName = curUser?.Claims.FirstOrDefault(c => c.Type.Equals("preferred_username"))?.Value, + Email = curUser?.Claims.FirstOrDefault(c => c.Type.Equals("email")) + ?.Value, + EmailConfirmed = true, // always confirmed (or rather, expect the IdP to confirm it) + Phones = null, // no phones or extensions by default + Extensions = null, + }; + if (user.Id == null) { + throw new InvalidCredentialException($"Missing required OIDC claim \"{ClaimTypes.NameIdentifier}\""); + } + + if (await _userManager.FindByIdAsync(user.Id) == null) { + var res = await _userManager.CreateAsync(user); + if (res.Succeeded == false) { + throw new InvalidCredentialException($"Creating identity failed: {res.Errors.FirstOrDefault()!.Description}"); + } + } + } + }; + + // if dev, disable secure + if (!builder.Environment.IsDevelopment()) return; + opts.NonceCookie.SecurePolicy = CookieSecurePolicy.None; + opts.NonceCookie.SameSite = SameSiteMode.Unspecified; + opts.CorrelationCookie.SecurePolicy = CookieSecurePolicy.None; + opts.CorrelationCookie.SameSite = SameSiteMode.Unspecified; + }); +builder.Services.AddAuthorization(opts => +{ + opts.AddPolicy("RequireAdmin", + policy => policy.RequireRole("Administrator")); +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Home/Error"); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} else { + app.UseDeveloperExceptionPage(); + app.UseMigrationsEndPoint(); +} + +using (var scope = app.Services.CreateScope()) { + var services = scope.ServiceProvider; + + var context = services.GetRequiredService(); + context.Database.EnsureCreated(); +} + +app.UseHttpsRedirection(); +app.UseStaticFiles(); + +app.UseRouting(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllerRoute( + name: "default", + pattern: "{controller=Home}/{action=Index}") + .RequireAuthorization(); + +app.Run(); diff --git a/PhoneToolMX/Properties/launchSettings.json b/PhoneToolMX/Properties/launchSettings.json new file mode 100644 index 0000000..1ecff00 --- /dev/null +++ b/PhoneToolMX/Properties/launchSettings.json @@ -0,0 +1,28 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:5205", + "sslPort": 44330 + } + }, + "profiles": { + "PhoneToolMX": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/PhoneToolMX/Views/Extension/DeleteConfirm.cshtml b/PhoneToolMX/Views/Extension/DeleteConfirm.cshtml new file mode 100644 index 0000000..2a7948b --- /dev/null +++ b/PhoneToolMX/Views/Extension/DeleteConfirm.cshtml @@ -0,0 +1,10 @@ +@model PhoneToolMX.Models.ViewModels.ExtensionVM + +@using (Html.BeginForm(FormMethod.Post)) { +

Are you sure you want to delete extension @(Model?.ExtId)?

+ +
+ + +
+} \ No newline at end of file diff --git a/PhoneToolMX/Views/Extension/Index.cshtml b/PhoneToolMX/Views/Extension/Index.cshtml new file mode 100644 index 0000000..86ad953 --- /dev/null +++ b/PhoneToolMX/Views/Extension/Index.cshtml @@ -0,0 +1,6 @@ +@using PhoneToolMX.Helpers +@model ICollection + +

This is a list of all extensions you can edit.

+ +@Html.MakeTable() \ No newline at end of file diff --git a/PhoneToolMX/Views/Extension/_Form.cshtml b/PhoneToolMX/Views/Extension/_Form.cshtml new file mode 100644 index 0000000..dce71b2 --- /dev/null +++ b/PhoneToolMX/Views/Extension/_Form.cshtml @@ -0,0 +1,33 @@ +@model PhoneToolMX.Models.ViewModels.ExtensionVM + + +

Fill out the fields below, then click "Submit" to @ViewData["Action"] the line.

+ +@using (Html.BeginForm(FormMethod.Post)) { + + @if (Model != null) { + @Html.HiddenFor(m => m.Id) + } + + + + + + + + + + + + + + +
@Html.LabelFor(m => m.DirectoryName, "Name in Directory:")@Html.TextBoxFor(m => m.DirectoryName)@Html.ValidationMessageFor(m => m.DirectoryName)
@Html.LabelFor(m => m.HoldMusic, "Hold Music:")@if (ViewBag.HoldMusics == null || ((ICollection)ViewBag.HoldMusics).Count == 0) { + @Html.DropDownListFor(m => m.HoldMusic, new List(), "-- No music available --", new { @disabled=true }) + } else { + @Html.DropDownListFor(m => m.HoldMusic, new SelectList(ViewBag.HoldMusics, "Id", "FriendlyName")) + }
@Html.CheckBoxFor(m => m.Listed) @Html.LabelFor(m => m.Listed, "Show extension in global directory")
+ + +
+} diff --git a/PhoneToolMX/Views/Home/Index.cshtml b/PhoneToolMX/Views/Home/Index.cshtml new file mode 100644 index 0000000..5b9ec34 --- /dev/null +++ b/PhoneToolMX/Views/Home/Index.cshtml @@ -0,0 +1,24 @@ +@{ + ViewData["Title"] = "Home Page"; +} + +

Welcome to PhoneToolMX! Select one of the options from the sidebar to begin.

+ +
+ +

News

+ +

Oct 17, 2023 :: Version 1.0

+ +

Finally, initial release! As far as user-facing features go, it's still pretty light. + But the scaffolding is there for new features like:

+ +
    +
  • Online Voicemail
  • +
  • Custom Media (Wallpapers, Hold Music, Ringtones)
  • +
  • Call Groups (Multiple Extensions -> One Number)
  • +
+ +

Even though this is v1.0, expect bugs, and please + report them if you see them.

+ @this.Url.Action("Error") \ No newline at end of file diff --git a/PhoneToolMX/Views/Home/Privacy.cshtml b/PhoneToolMX/Views/Home/Privacy.cshtml new file mode 100644 index 0000000..2479fb7 --- /dev/null +++ b/PhoneToolMX/Views/Home/Privacy.cshtml @@ -0,0 +1,6 @@ +@{ + ViewData["Title"] = "Privacy Policy"; +} +

@ViewData["Title"]

+ +

Use this page to detail your site's privacy policy.

diff --git a/PhoneToolMX/Views/Model/Index.cshtml b/PhoneToolMX/Views/Model/Index.cshtml new file mode 100644 index 0000000..6836435 --- /dev/null +++ b/PhoneToolMX/Views/Model/Index.cshtml @@ -0,0 +1,7 @@ +@using PhoneToolMX.Helpers +@model ICollection +@{ + ViewData["Title"] = "Phone Models"; +} + +@Html.MakeTable() \ No newline at end of file diff --git a/PhoneToolMX/Views/Phone/DeleteConfirm.cshtml b/PhoneToolMX/Views/Phone/DeleteConfirm.cshtml new file mode 100644 index 0000000..e526ba2 --- /dev/null +++ b/PhoneToolMX/Views/Phone/DeleteConfirm.cshtml @@ -0,0 +1,13 @@ +@model PhoneToolMX.Models.ViewModels.PhoneVM +@{ + ViewData["Title"] = $"Deactivate {Model?.FriendlyName}"; +} + +@using (Html.BeginForm(FormMethod.Post)) { +

Are you sure you want to deactivate phone @(Model?.FriendlyName)? This will not delete any associated extensions.

+ +
+ + +
+} \ No newline at end of file diff --git a/PhoneToolMX/Views/Phone/Index.cshtml b/PhoneToolMX/Views/Phone/Index.cshtml new file mode 100644 index 0000000..9858f00 --- /dev/null +++ b/PhoneToolMX/Views/Phone/Index.cshtml @@ -0,0 +1,6 @@ +@using PhoneToolMX.Helpers +@model ICollection + +

This is a list of all phones you manage.

+ +@Html.MakeTable() \ No newline at end of file diff --git a/PhoneToolMX/Views/Phone/_Form.cshtml b/PhoneToolMX/Views/Phone/_Form.cshtml new file mode 100644 index 0000000..aa5e92c --- /dev/null +++ b/PhoneToolMX/Views/Phone/_Form.cshtml @@ -0,0 +1,34 @@ +@model PhoneToolMX.Models.ViewModels.PhoneVM + + +

Fill out the fields below, then click "Submit" to @ViewData["Action"] the phone.

+ +@using (Html.BeginForm(FormMethod.Post)) { + + @if (Model != null) { + @Html.HiddenFor(m => m.Id) + } + + + + + + + + + + + + + + + + + + +
@Html.LabelFor(m => m.MacAddress, "MAC Address:")@Html.TextBoxFor(m => m.MacAddress)@Html.ValidationMessageFor(m => m.MacAddress)
@Html.LabelFor(m => m.FriendlyName, "Phone Name:")@Html.TextBoxFor(m => m.FriendlyName)@Html.ValidationMessageFor(m => m.FriendlyName)
@Html.LabelFor(m => m.Model, "Phone Model:")@Html.DropDownListFor(m => m.Model, new SelectList(ViewBag.ModelNumbers, "Id", "ModelName", ViewBag.CurrentModel))@Html.ValidationMessageFor(m => m.Model)
@Html.LabelFor(m => m.Extensions, "Extensions:")@Html.ListBoxFor(m => m.Extensions, new MultiSelectList(ViewBag.MyExtensions, "Id", "ListViewName", ViewBag.SelectedExtensions)) +
+ + +
+} diff --git a/PhoneToolMX/Views/Shared/Error.cshtml b/PhoneToolMX/Views/Shared/Error.cshtml new file mode 100644 index 0000000..654a41a --- /dev/null +++ b/PhoneToolMX/Views/Shared/Error.cshtml @@ -0,0 +1,14 @@ +@model ErrorViewModel +@{ + ViewData["Title"] = "Something went wrong!"; +} + +

An unknown error occurred while processing your request.

+ +@if (Model?.ShowRequestId == true) { +

Provide the following request ID to the application administrator, or include it in a bug report:

+ +
    +
  • @Model.RequestId
  • +
+} \ No newline at end of file diff --git a/PhoneToolMX/Views/Shared/_Layout.cshtml b/PhoneToolMX/Views/Shared/_Layout.cshtml new file mode 100644 index 0000000..bd985df --- /dev/null +++ b/PhoneToolMX/Views/Shared/_Layout.cshtml @@ -0,0 +1,89 @@ +@using PhoneToolMX.Helpers +@using System.Text.Json + +@{ + var userIdent = (System.Security.Claims.ClaimsIdentity)User.Identity; +} + + +@* ReSharper disable once MissingTitleTag *@ + + + + + @ViewData["Title"] - PhoneToolMX + + + + + + + + + + + + + + + + + + + + +
+ @await RenderSectionAsync("CtxOptions", false) + + + @if (userIdent != null) { + Hello, @userIdent.Name! @("[")Logout@("]") + } else { + Login + } +
+

@ViewData["Title"]

+ @if (TempData["Message"] != null) { + var msg = JsonSerializer.Deserialize((string)TempData["Message"]); +
+ @msg.Message +
+ } + @RenderBody() +
+ + diff --git a/PhoneToolMX/Views/Shared/_Layout.cshtml.css b/PhoneToolMX/Views/Shared/_Layout.cshtml.css new file mode 100644 index 0000000..dc7c1ee --- /dev/null +++ b/PhoneToolMX/Views/Shared/_Layout.cshtml.css @@ -0,0 +1,49 @@ +/* Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification +for details on configuring this project to bundle and minify static web assets. */ + +a.navbar-brand { + white-space: normal; + text-align: center; + word-break: break-all; +} + +a { + color: #0077cc; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.nav-pills .nav-link.active, .nav-pills .show > .nav-link { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.border-top { + border-top: 1px solid #e5e5e5; +} +.border-bottom { + border-bottom: 1px solid #e5e5e5; +} + +.box-shadow { + box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05); +} + +button.accept-policy { + font-size: 1rem; + line-height: inherit; +} + +.footer { + position: absolute; + bottom: 0; + width: 100%; + white-space: nowrap; + line-height: 60px; +} + diff --git a/PhoneToolMX/Views/_ViewImports.cshtml b/PhoneToolMX/Views/_ViewImports.cshtml new file mode 100644 index 0000000..336102d --- /dev/null +++ b/PhoneToolMX/Views/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@using PhoneToolMX +@using PhoneToolMX.Models +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/PhoneToolMX/Views/_ViewStart.cshtml b/PhoneToolMX/Views/_ViewStart.cshtml new file mode 100644 index 0000000..6e88aa3 --- /dev/null +++ b/PhoneToolMX/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/PhoneToolMX/appsettings.Development.json b/PhoneToolMX/appsettings.Development.json new file mode 100644 index 0000000..b7cc1a1 --- /dev/null +++ b/PhoneToolMX/appsettings.Development.json @@ -0,0 +1,18 @@ +{ + "Urls": "http://localhost:5001", + "ConnectionStrings": { + "DbConnection": "Host=yuuka.i.2ki.xyz; Database=ptmx_dev; Username=flurry; Password=PeE4PKpMUE2qPja4FyGrvB8FVArZKWa5YReFbfRW649chx38" + }, + "OpenIdConnect": { + "ClientId": "3f2b1348-6d6b-4332-8c43-0a7a5707ccbf", + "ClientSecret": "gto_ljwf22pbzaop7tv6nv2upf6binv7siybye7xu5ytecfrdlils6ra", + "MetadataUrl": "https://git.2ki.xyz/.well-known/openid-configuration", + "Authority": "https://git.2ki.xyz" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/PhoneToolMX/appsettings.json b/PhoneToolMX/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/PhoneToolMX/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/PhoneToolMX/wwwroot/favicon.ico b/PhoneToolMX/wwwroot/favicon.ico new file mode 100644 index 0000000..63e859b Binary files /dev/null and b/PhoneToolMX/wwwroot/favicon.ico differ diff --git a/PhoneToolMX/wwwroot/images/banner.gif b/PhoneToolMX/wwwroot/images/banner.gif new file mode 100644 index 0000000..428e5c7 Binary files /dev/null and b/PhoneToolMX/wwwroot/images/banner.gif differ diff --git a/PhoneToolMX/wwwroot/images/del_x16.gif b/PhoneToolMX/wwwroot/images/del_x16.gif new file mode 100644 index 0000000..59718aa Binary files /dev/null and b/PhoneToolMX/wwwroot/images/del_x16.gif differ diff --git a/PhoneToolMX/wwwroot/images/edit_x16.gif b/PhoneToolMX/wwwroot/images/edit_x16.gif new file mode 100644 index 0000000..94026ad Binary files /dev/null and b/PhoneToolMX/wwwroot/images/edit_x16.gif differ diff --git a/PhoneToolMX/wwwroot/main.css b/PhoneToolMX/wwwroot/main.css new file mode 100644 index 0000000..317e7ad --- /dev/null +++ b/PhoneToolMX/wwwroot/main.css @@ -0,0 +1,182 @@ +body { +background-color:#333333; +} + +table#main { +margin-left:auto; +margin-right:auto; +width:722px; +color:#000000; +font-family:Geneva, Arial, Helvetica, sans-serif; +font-size:12px; +border:none; +} + +table#main td { +background-color:#CCCCCC; +padding:0.45em; +border: 1px solid #000000; +} + +/* +table#main table td { +padding:unset; +border:none; +} +*/ + +form table input[type="text"], +form table select { +width:100%; + box-sizing: border-box; + -moz-box-sizing: border-box; +} + +table#main td#header { +background-color:#ffffff; +height:150px; +padding: 0; +margin:0; +} + +table#main td#header img { +width:720px; +height:150px; +padding:0; +display:block; +margin:1px; +} + +table#main td#nav { +background-color:#ccccff; +} + +table#main td#nav, table#main td#content { +vertical-align:top; +} + +table#main td#nav ul { +list-style-type:square; +margin: 0.5em 0 0.5em 1em; +padding: 0; +} + +table#main td#nav ul li { +margin:0 0 0.25em 0.5em; +padding: 0; +} + +table#main td#footer { +background-color:#000000; +color:#ffffff; +font-size:10px; +} + +table#main td#footer a:link { +color:#00CCFF; +} + +table#main td#context, +table#main td#userinfo { +background-color:#000000; +color: #ffffff; +height:1.5em; +vertical-align:middle; +} + +table#main td#userinfo a:link, +table#main td#userinfo a:visited, +table#main td#userinfo a:active, +table#main td#footer a:link, +table#main td#footer a:visited, +table#main td#footer a:active { + color: #ffffff; +} + +table#main td#context form label, +table#main td#context form select, +table#main td#context form input { +display:table-cell; +vertical-align:middle; +} + +table#main td#context form select, +table#main td#context form input { +height:1.5em; +font-size:1em; +padding:0; +} + +table#main td#userinfo { +text-align:right; +} + +table#main td#content { +padding:1em; +} + +td#content h1, +td#content h2, +td#content h3 { +margin-top:0.25em; +margin-bottom:0.25em; +} + +table#listview { + width: 100%; + border: 1px outset; + border-spacing: 1px; + background-color: #666; +} + +table#listview td, +table#listview th { + padding: 0.25em; + border: 1px inset; +} + +table#listview thead { + background-color: #aaa; + border: 1px #aaa inset; +} + +table#main form table td { + border: unset; +} + +div.status { + border: 2px ridge; + margin: 0.25em 0; + padding: 0.5em; +} + +div.status.msg-success { + border-color: #4a4; + color: #161; + background-color: #bfb; +} + +div.status.msg-warning { + border-color: #aa4; + color: #661; + background-color: #ffb; +} + +div.status.msg-error { + border-color: #a44; + color: #611; + background-color: #fbb; +} + +div.status.msg-info { + border-color: #444; + color: #111; + background-color: #bbb; +} + +span.field-validation-error { + width: 100%; + display: block; + font-size: 10px; + color: #c00; +} diff --git a/global.json b/global.json new file mode 100644 index 0000000..9e5e1fd --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "6.0.0", + "rollForward": "latestMajor", + "allowPrerelease": true + } +} \ No newline at end of file diff --git a/ptmx-asp.sln b/ptmx-asp.sln new file mode 100644 index 0000000..95b2a66 --- /dev/null +++ b/ptmx-asp.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30114.105 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhoneToolMX", "PhoneToolMX\PhoneToolMX.csproj", "{130DD8E2-5E99-47A9-8B30-2FF54A0DA89D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhoneToolMX.Models", "PhoneToolMX.Models\PhoneToolMX.Models.csproj", "{84FB1CE2-A4B6-4251-9427-8AEB175D6874}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {130DD8E2-5E99-47A9-8B30-2FF54A0DA89D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {130DD8E2-5E99-47A9-8B30-2FF54A0DA89D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {130DD8E2-5E99-47A9-8B30-2FF54A0DA89D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {130DD8E2-5E99-47A9-8B30-2FF54A0DA89D}.Release|Any CPU.Build.0 = Release|Any CPU + {84FB1CE2-A4B6-4251-9427-8AEB175D6874}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {84FB1CE2-A4B6-4251-9427-8AEB175D6874}.Debug|Any CPU.Build.0 = Debug|Any CPU + {84FB1CE2-A4B6-4251-9427-8AEB175D6874}.Release|Any CPU.ActiveCfg = Release|Any CPU + {84FB1CE2-A4B6-4251-9427-8AEB175D6874}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/ptmx-asp.sln.DotSettings b/ptmx-asp.sln.DotSettings new file mode 100644 index 0000000..14592c7 --- /dev/null +++ b/ptmx-asp.sln.DotSettings @@ -0,0 +1,3 @@ + + True + True \ No newline at end of file