Compare commits

...

5 commits

Author SHA1 Message Date
snow flurry ea21d62c03 AsterNET: Use custom fork 2023-10-21 13:51:24 -07:00
snow flurry 4268e4d7d9 Add AMI integration 2023-10-21 13:46:56 -07:00
snow flurry 69c9a0333c Add AsterNET as a dependency 2023-10-21 13:45:38 -07:00
snow flurry 060b4ee1a1 Remove debug lines 2023-10-20 14:33:47 -07:00
snow flurry fa882a1f4a Support primary extension 2023-10-20 14:32:56 -07:00
25 changed files with 993 additions and 18 deletions

4
.gitmodules vendored Normal file
View file

@ -0,0 +1,4 @@
[submodule "external/AsterNET"]
path = external/AsterNET
url = https://git.2ki.xyz/snow/AsterNET.git
branch = master

View file

@ -0,0 +1,7 @@
namespace PhoneToolMX.Models
{
public class AllowNullAttribute : Attribute
{
}
}

View file

@ -38,6 +38,20 @@ namespace PhoneToolMX.Data {
var entry = await AddAsync(entity);
return entry;
}
/// <summary>
/// Gets a DbSet with all required relationships
/// </summary>
/// <typeparam name="TEntity">IOwnedModel</typeparam>
/// <returns>DbSet&lt;TEntity&gt;</returns>
private IQueryable<TEntity> GetFullSet<TEntity>() where TEntity: class, IModel
{
var set = Set<TEntity>().AsQueryable();
return typeof(TEntity).GetProperties()
.Where(p => p.GetCustomAttributes(typeof(AlwaysIncludeAttribute), true)
.Length != 0)
.Aggregate(set, (current, prop) => current.Include(prop.Name));
}
/// <summary>
/// Gets all entities of a certain model owned by the <see cref="User"/>.
@ -48,14 +62,11 @@ namespace PhoneToolMX.Data {
public ICollection<TEntity> GetOwned<TEntity>(User owner) where TEntity : class, IOwnedModel
{
if (owner == null) return null;
var entity = Set<TEntity>().Where(x => x.Owners.Any(o => o.Id == owner.Id));
var entity = GetFullSet<TEntity>().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();
}
/// <summary>
/// Gets a model entity by its Id
@ -67,7 +78,7 @@ namespace PhoneToolMX.Data {
/// </remarks>
public TEntity GetEntityById<TEntity>(int? id) where TEntity : class, IModel
{
return id == null ? null : Set<TEntity>().FirstOrDefault(o => o.Id == id);
return id == null ? null : GetFullSet<TEntity>().FirstOrDefault(o => o.Id == id);
}
#endregion
@ -79,6 +90,7 @@ namespace PhoneToolMX.Data {
ext.HasKey(x => x.Id);
ext.Property(x => x.Id).UseIdentityColumn();
ext.Property(x => x.ExtId).HasComputedColumnSql("\"Id\" + 1000", stored: true);
// Randomly generates a 24-char password
ext
.Property(p => p.Password)
.HasDefaultValueSql("encode(gen_random_bytes(18), 'base64')");
@ -89,7 +101,7 @@ namespace PhoneToolMX.Data {
.WithMany(x => x.Phones);
phone.HasKey(p => p.Id);
phone.Property(p => p.Id).UseIdentityColumn();
// Randomly generates a 24-char password
phone.HasOne(p => p.PrimaryExtension).WithMany();
// Wallpapers, ringtones, etc
var cd = modelBuilder.Entity<CustomData>();

View file

@ -0,0 +1,361 @@
// <auto-generated />
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("20231020205952_PrimaryExtension")]
partial class PrimaryExtension
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "6.0.23")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ExtensionPhone", b =>
{
b.Property<int>("ExtensionsId")
.HasColumnType("integer");
b.Property<int>("PhonesId")
.HasColumnType("integer");
b.HasKey("ExtensionsId", "PhonesId");
b.HasIndex("PhonesId");
b.ToTable("ExtensionPhone");
});
modelBuilder.Entity("ExtensionUser", b =>
{
b.Property<int>("ExtensionsId")
.HasColumnType("integer");
b.Property<string>("OwnersId")
.HasColumnType("text");
b.HasKey("ExtensionsId", "OwnersId");
b.HasIndex("OwnersId");
b.ToTable("ExtensionUser");
});
modelBuilder.Entity("PhoneToolMX.Models.CustomData", b =>
{
b.Property<int?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int?>("Id"));
b.Property<byte[]>("Data")
.HasColumnType("bytea");
b.Property<int>("DataType")
.HasColumnType("integer");
b.Property<string>("FriendlyName")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<int?>("PhoneId")
.HasColumnType("integer");
b.Property<long>("Size")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("PhoneId");
b.ToTable("CustomData");
});
modelBuilder.Entity("PhoneToolMX.Models.Extension", b =>
{
b.Property<int?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int?>("Id"));
b.Property<string>("DirectoryName")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ExtId")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("integer")
.HasComputedColumnSql("\"Id\" + 1000", true);
b.Property<int?>("HoldMusicId")
.HasColumnType("integer");
b.Property<bool>("Listed")
.HasColumnType("boolean");
b.Property<string>("Password")
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValueSql("encode(gen_random_bytes(18), 'base64')");
b.HasKey("Id");
b.HasIndex("HoldMusicId");
b.ToTable("Extensions");
});
modelBuilder.Entity("PhoneToolMX.Models.Phone", b =>
{
b.Property<int?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int?>("Id"));
b.Property<int?>("BackgroundId")
.HasColumnType("integer");
b.Property<string>("FriendlyName")
.IsRequired()
.HasColumnType("text");
b.Property<PhysicalAddress>("MacAddress")
.IsRequired()
.HasColumnType("macaddr");
b.Property<int>("ModelId")
.HasColumnType("integer");
b.Property<int?>("PrimaryExtension")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("BackgroundId");
b.HasIndex("ModelId");
b.ToTable("Phones");
});
modelBuilder.Entity("PhoneToolMX.Models.PhoneModel", b =>
{
b.Property<int?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int?>("Id"));
b.Property<long>("MaxExtensions")
.HasColumnType("bigint");
b.Property<string>("ModelName")
.HasColumnType("text");
b.Property<bool>("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<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("NormalizedName")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Role");
});
modelBuilder.Entity("PhoneToolMX.Models.User", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasColumnType("text");
b.Property<string>("NormalizedUserName")
.HasColumnType("text");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("PhoneUser", b =>
{
b.Property<string>("OwnersId")
.HasColumnType("text");
b.Property<int>("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
}
}
}

View file

@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PhoneToolMX.Models.Migrations
{
public partial class PrimaryExtension : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PrimaryExtension",
table: "Phones",
type: "integer",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PrimaryExtension",
table: "Phones");
}
}
}

View file

@ -0,0 +1,369 @@
// <auto-generated />
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("20231020213049_PrimaryExtensionAsExtension")]
partial class PrimaryExtensionAsExtension
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "6.0.23")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ExtensionPhone", b =>
{
b.Property<int>("ExtensionsId")
.HasColumnType("integer");
b.Property<int>("PhonesId")
.HasColumnType("integer");
b.HasKey("ExtensionsId", "PhonesId");
b.HasIndex("PhonesId");
b.ToTable("ExtensionPhone");
});
modelBuilder.Entity("ExtensionUser", b =>
{
b.Property<int>("ExtensionsId")
.HasColumnType("integer");
b.Property<string>("OwnersId")
.HasColumnType("text");
b.HasKey("ExtensionsId", "OwnersId");
b.HasIndex("OwnersId");
b.ToTable("ExtensionUser");
});
modelBuilder.Entity("PhoneToolMX.Models.CustomData", b =>
{
b.Property<int?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int?>("Id"));
b.Property<byte[]>("Data")
.HasColumnType("bytea");
b.Property<int>("DataType")
.HasColumnType("integer");
b.Property<string>("FriendlyName")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<int?>("PhoneId")
.HasColumnType("integer");
b.Property<long>("Size")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("PhoneId");
b.ToTable("CustomData");
});
modelBuilder.Entity("PhoneToolMX.Models.Extension", b =>
{
b.Property<int?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int?>("Id"));
b.Property<string>("DirectoryName")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ExtId")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("integer")
.HasComputedColumnSql("\"Id\" + 1000", true);
b.Property<int?>("HoldMusicId")
.HasColumnType("integer");
b.Property<bool>("Listed")
.HasColumnType("boolean");
b.Property<string>("Password")
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValueSql("encode(gen_random_bytes(18), 'base64')");
b.HasKey("Id");
b.HasIndex("HoldMusicId");
b.ToTable("Extensions");
});
modelBuilder.Entity("PhoneToolMX.Models.Phone", b =>
{
b.Property<int?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int?>("Id"));
b.Property<int?>("BackgroundId")
.HasColumnType("integer");
b.Property<string>("FriendlyName")
.IsRequired()
.HasColumnType("text");
b.Property<PhysicalAddress>("MacAddress")
.IsRequired()
.HasColumnType("macaddr");
b.Property<int>("ModelId")
.HasColumnType("integer");
b.Property<int?>("PrimaryExtensionId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("BackgroundId");
b.HasIndex("ModelId");
b.HasIndex("PrimaryExtensionId");
b.ToTable("Phones");
});
modelBuilder.Entity("PhoneToolMX.Models.PhoneModel", b =>
{
b.Property<int?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int?>("Id"));
b.Property<long>("MaxExtensions")
.HasColumnType("bigint");
b.Property<string>("ModelName")
.HasColumnType("text");
b.Property<bool>("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<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("NormalizedName")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Role");
});
modelBuilder.Entity("PhoneToolMX.Models.User", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasColumnType("text");
b.Property<string>("NormalizedUserName")
.HasColumnType("text");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("PhoneUser", b =>
{
b.Property<string>("OwnersId")
.HasColumnType("text");
b.Property<int>("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.HasOne("PhoneToolMX.Models.Extension", "PrimaryExtension")
.WithMany()
.HasForeignKey("PrimaryExtensionId");
b.Navigation("Background");
b.Navigation("Model");
b.Navigation("PrimaryExtension");
});
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
}
}
}

View file

@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PhoneToolMX.Models.Migrations
{
public partial class PrimaryExtensionAsExtension : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "PrimaryExtension",
table: "Phones",
newName: "PrimaryExtensionId");
migrationBuilder.CreateIndex(
name: "IX_Phones_PrimaryExtensionId",
table: "Phones",
column: "PrimaryExtensionId");
migrationBuilder.AddForeignKey(
name: "FK_Phones_Extensions_PrimaryExtensionId",
table: "Phones",
column: "PrimaryExtensionId",
principalTable: "Extensions",
principalColumn: "Id");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Phones_Extensions_PrimaryExtensionId",
table: "Phones");
migrationBuilder.DropIndex(
name: "IX_Phones_PrimaryExtensionId",
table: "Phones");
migrationBuilder.RenameColumn(
name: "PrimaryExtensionId",
table: "Phones",
newName: "PrimaryExtension");
}
}
}

View file

@ -141,12 +141,17 @@ namespace PhoneToolMX.Models.Migrations
b.Property<int>("ModelId")
.HasColumnType("integer");
b.Property<int?>("PrimaryExtensionId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("BackgroundId");
b.HasIndex("ModelId");
b.HasIndex("PrimaryExtensionId");
b.ToTable("Phones");
});
@ -326,9 +331,15 @@ namespace PhoneToolMX.Models.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PhoneToolMX.Models.Extension", "PrimaryExtension")
.WithMany()
.HasForeignKey("PrimaryExtensionId");
b.Navigation("Background");
b.Navigation("Model");
b.Navigation("PrimaryExtension");
});
modelBuilder.Entity("PhoneUser", b =>

View file

@ -12,6 +12,8 @@ namespace PhoneToolMX.Models
foreach (var prop in GetType().GetProperties().Where(p => p.CanWrite)) {
if (prop.GetValue(obj, null) is {} propVal) {
prop.SetValue(this, propVal, null);
} else if (prop.GetCustomAttributes(typeof(AllowNullAttribute)).FirstOrDefault() != null) {
prop.SetValue(this, null, null);
}
}
}

View file

@ -21,7 +21,10 @@ namespace PhoneToolMX.Models {
[AlwaysInclude]
public ICollection<Extension> Extensions { get; set; }
public CustomData Background { get; set; }
public ICollection<CustomData> Ringtones { get; set; }
public Extension PrimaryExtension { get; set; }
}
}

View file

@ -53,5 +53,7 @@ namespace PhoneToolMX.Models.ViewModels
HoldMusic = extEnt.HoldMusic?.Id,
};
}
// TODO: fix hack
public string NotifyOnChange() => (ExtId == 0 ? Id + 1000 : ExtId).ToString();
}
}

View file

@ -6,10 +6,12 @@ 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);
public string NotifyOnChange();
}
}

View file

@ -1,6 +1,4 @@
using PhoneToolMX.Data;
using PhoneToolMX.Models;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Net.NetworkInformation;
@ -10,7 +8,7 @@ namespace PhoneToolMX.Models.ViewModels
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")]
@ -25,18 +23,25 @@ namespace PhoneToolMX.Models.ViewModels
public ICollection<int?> Extensions { get; set; }
[AllowNull]
public int? PrimaryExtension { get; set; }
public int MaxExtensions { get; set; }
public IOwnedModel ToEntity(PTMXContext ctx)
{
var exts = Extensions?.Select(x => ctx.Extensions.FirstOrDefault(e => e.Id == x)).ToList();
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(),
Extensions = exts,
Owners = new List<User>(),
PrimaryExtension = PrimaryExtension != null
? exts?.FirstOrDefault(x => x.Id == PrimaryExtension)
: null,
};
}
@ -58,7 +63,13 @@ namespace PhoneToolMX.Models.ViewModels
Model = phoneEnt.Model!.Id,
Extensions = phoneEnt.Extensions?.Select(x => x.Id).ToList(),
MaxExtensions = (int)phoneEnt.Model!.MaxExtensions,
PrimaryExtension = phoneEnt.PrimaryExtension?.Id,
};
}
// a bit of a hack, but we can just choose any extension that belongs to this phone
public string NotifyOnChange() => Extensions.Count > 0
? (Extensions.First() + 1000).ToString()
: null;
}
}

View file

@ -1,3 +1,4 @@
using AsterNET.Manager;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@ -5,6 +6,7 @@ using PhoneToolMX.Data;
using PhoneToolMX.Helpers;
using PhoneToolMX.Models;
using PhoneToolMX.Models.ViewModels;
using PhoneToolMX.Services;
using System.Security.Authentication;
using System.Security.Claims;
@ -118,7 +120,7 @@ namespace PhoneToolMX.Controllers
SetMessage(
FormMessageType.Success,
$"{typeof(T).Name} {GetFriendlyName(new TViewModel().FromEntity(entity.Entity))} was created."
$"{typeof(T).Name} {GetFriendlyName(entity.Entity)} was created."
);
return RedirectToAction("Edit", new { id = entity.Entity.Id });
@ -170,9 +172,22 @@ namespace PhoneToolMX.Controllers
_context.Set<T>().Update(currentModel);
await _context.SaveChangesAsync();
// Try to notify the relevant endpoint to update its config
var amiStatus = "";
if (HttpContext.RequestServices.GetService<IAsteriskManager>() is {} ami) {
try { await ami.SendNotifyAsync(vm.NotifyOnChange());
amiStatus = "Your phone's configuration should be updated shortly.";
}
catch (BadResponseException e) { amiStatus = $"You'll need to manually update your phone settings (AMI Response: {e.Message})."; }
catch (ManagerException e) { amiStatus = $"You'll need to manually update your phone settings (AMI Client Error: {e.Message})."; }
catch (Exception e) {
amiStatus = $"You'll need to manually update your phone settings (Unknown Error: {e.Message}).";
}
}
SetMessage(
FormMessageType.Success,
$"{typeof(T).Name} {GetFriendlyName(vm)} was updated."
$"{typeof(T).Name} {GetFriendlyName(currentModel)} was updated.{" " + amiStatus}"
);
return RedirectToAction("Edit", new { id = entity.Id });

View file

@ -17,8 +17,10 @@ namespace PhoneToolMX.Controllers
{
var myExts = _context.GetOwned<Extension>(await CurrentUser());
var phoneModels = _context.PhoneModels.ToList();
var selectedExts = pvm?.Extensions == null ? null : myExts.Where(x => pvm.Extensions.Contains(x.Id)).ToList();
ViewBag.MyExtensions = myExts;
ViewBag.SelectedExtensions = pvm?.Extensions == null ? null : myExts.Where(x => pvm.Extensions.Contains(x.Id)).ToList();
ViewBag.SelectedExtensions = selectedExts;
ViewBag.PrimaryExtension = selectedExts?.FirstOrDefault(x => x.Id == (pvm.PrimaryExtension ?? -1));
ViewBag.ModelNumbers = phoneModels;
ViewBag.CurrentModel = pvm?.Model == null ? null : phoneModels.Where(m => m.Id == pvm.Model);
}

View file

@ -23,6 +23,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\external\AsterNET\Asterisk.2013\Asterisk.NET\AsterNET.csproj" />
<ProjectReference Include="..\PhoneToolMX.Models\PhoneToolMX.Models.csproj" />
</ItemGroup>

View file

@ -10,6 +10,7 @@ using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using NuGet.Packaging;
using PhoneToolMX.Models;
using PhoneToolMX.Services;
using System.Net;
using System.Security.Authentication;
using System.Security.Claims;
@ -39,11 +40,8 @@ builder.Services.AddIdentityCore<User>(opts =>
.AddUserManager<UserManager<User>>()
.AddEntityFrameworkStores<PTMXContext>();
Console.WriteLine("Testing one two");
var proxyConfig = builder.Configuration.GetSection("Proxies");
if (proxyConfig?.GetSection("TrustedProxies")?.Get<IList<string>>() is {} trustedProxies) {
Console.WriteLine("Got trusted proxies!");
builder.Services.Configure<ForwardedHeadersOptions>(opts =>
{
opts.KnownProxies.AddRange(trustedProxies.Select(IPAddress.Parse));
@ -115,6 +113,11 @@ builder.Services.AddAuthorization(opts =>
policy => policy.RequireRole("Administrator"));
});
// AMI for PJSIP notifications
if (builder.Configuration.GetSection("ami").Exists()) {
builder.Services.AddSingleton<IAsteriskManager, AsteriskManager>();
}
var app = builder.Build();
// Configure the HTTP request pipeline.

View file

@ -0,0 +1,36 @@
using System.Collections;
using System.Net.Security;
using System.Net.Sockets;
using System.Text;
using AsterNET.Manager;
namespace PhoneToolMX.Services
{
public class AsteriskManager : IAsteriskManager
{
private IConfigurationSection amiConf;
private ManagerConnection conn;
public AsteriskManager(IConfiguration config)
{
amiConf = config.GetRequiredSection("ami");
conn = new ManagerConnection(amiConf["host"],
int.Parse(amiConf["port"]),
amiConf["username"],
amiConf["secret"]);
conn.Login();
}
/// <summary>
/// Sends a <c>polycom-check-cfg</c> notification to the requested PJSIP endpoint.
/// </summary>
/// <param name="endpoint">The PJSIP endpoint to notify, without the <c>PJSIP/</c> prefix.</param>
/// <exception cref="BadResponseException">If the request fails, this exception will be thrown.</exception>
public async Task SendNotifyAsync(string endpoint)
{
var response = await conn.SendActionAsync(new PJSIPNotifyAction(endpoint, "polycom-check-cfg"));
if (!response.IsSuccess()) {
throw new BadResponseException(response.Message);
}
}
}
}

View file

@ -0,0 +1,7 @@
namespace PhoneToolMX.Services
{
public class BadResponseException : Exception
{
public BadResponseException(string message) : base(message) {}
}
}

View file

@ -0,0 +1,7 @@
namespace PhoneToolMX.Services
{
public interface IAsteriskManager
{
public Task SendNotifyAsync(string endpoint);
}
}

View file

@ -0,0 +1,25 @@
namespace PhoneToolMX.Services
{
public class PJSIPNotifyAction : AsterNET.Manager.Action.ManagerAction
{
public override string Action => "PJSIPNotify";
/// <summary>
/// The endpoint to which to send the NOTIFY.
/// </summary>
public string Endpoint { get; set; }
/// <summary>
/// The config section name from <c>pjsip_notify.conf</c> to use.
/// One of Option or Variable must be specified.
/// </summary>
public string Option { get; set; }
public PJSIPNotifyAction(string endpoint, string option)
{
Endpoint = endpoint;
Option = option;
}
}
}

View file

@ -24,6 +24,14 @@
<th>@Html.LabelFor(m => m.Extensions, "Extensions:")</th>
<td>@Html.ListBoxFor(m => m.Extensions, new MultiSelectList(ViewBag.MyExtensions, "Id", "ListViewName", ViewBag.SelectedExtensions))
</tr>
<tr>
<th>@Html.LabelFor(m => m.PrimaryExtension, "Primary Extension:")</th>
<td>@if (ViewBag.SelectedExtensions == null || ((ICollection<Extension>)ViewBag.SelectedExtensions).Count == 0) {
@Html.DropDownListFor(m => m.PrimaryExtension, new List<SelectListItem>(), "-- No Extensions Selected --", new { @disabled=true })
} else {
@Html.DropDownListFor(m => m.PrimaryExtension, new SelectList(ViewBag.SelectedExtensions, "Id", "ListViewName", ViewBag.PrimaryExtension), "None")
}</td>
</tr>
<tr>
<td colspan="2" align="right">
<input type="reset" value="Reset" />

View file

@ -54,6 +54,14 @@ namespace PolyProv.Controllers
public IActionResult PhoneSettings(string addr)
{
var phone = GetByMacStr(addr);
// ReSharper disable once InvertIf
if (phone?.PrimaryExtension is {} primary && phone.Extensions.FirstOrDefault() != primary) {
// primary extension exists and is not first, reorder so it's first
phone.Extensions.Remove(primary);
phone.Extensions = phone.Extensions.Prepend(primary).ToList();
}
return phone != null ? View("Phone", phone) : NotFound();
}

1
external/AsterNET vendored Submodule

@ -0,0 +1 @@
Subproject commit ae89dc5c61343bb8f3990fbf34656fc9555aae75

View file

@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhoneToolMX.Models", "Phone
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolyProv", "PolyProv\PolyProv.csproj", "{18199094-90AE-4C58-BE9E-65D486ECCC38}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AsterNET", "external\AsterNET\Asterisk.2013\Asterisk.NET\AsterNET.csproj", "{DBBFEA95-BA6F-4D5D-93A3-7DC59A63BBDE}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -39,5 +41,11 @@ Global
{18199094-90AE-4C58-BE9E-65D486ECCC38}.Release|Any CPU.Build.0 = Release|Any CPU
{18199094-90AE-4C58-BE9E-65D486ECCC38}.Release|x64.ActiveCfg = Release|Any CPU
{18199094-90AE-4C58-BE9E-65D486ECCC38}.Release|x64.Build.0 = Release|Any CPU
{DBBFEA95-BA6F-4D5D-93A3-7DC59A63BBDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DBBFEA95-BA6F-4D5D-93A3-7DC59A63BBDE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DBBFEA95-BA6F-4D5D-93A3-7DC59A63BBDE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DBBFEA95-BA6F-4D5D-93A3-7DC59A63BBDE}.Release|Any CPU.Build.0 = Release|Any CPU
{DBBFEA95-BA6F-4D5D-93A3-7DC59A63BBDE}.Release|x64.ActiveCfg = Release|Any CPU
{DBBFEA95-BA6F-4D5D-93A3-7DC59A63BBDE}.Release|x64.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal