We will learn how to build web apps with ASP.NET 5 MVC and Entity Framework 7
What are we going to build?
In short we are going to build a simple CMS app to manage content of a fictional technical talk event. we named it “TechTalkers”. The app has an admin panel where an admin can manage viewable contents for the app users. we made a little user story for our “TechTalkers” application along with the technology stack that we are going to use.
- User Story:
1.1 Admin
Admin logout/login system
Admin Panel
Managing Speakers
Managing Speech Topic associate with a speaker
1.2 User
A viewable website
Speakers List
Speaker Details
Topic List
Topic Details
- Technology Stack:
2.1 ASP.NET 5
MVC
Razor View Engine
Authentication & Authorization
Identity
2.2 Entity Framework
ORM (object relational mapping) for managing application data with a local database.
Entity framework code first
We are going to use Visual Studio 2015. Configuring Mac and Linux for ASP.net 5 is easy as pie.
There you will find how to set things up for ASP.NET 5 on a MAC or LINUX and start developing using Visual Studio Code or whatever Editor you want.
Open up Visual Studio 2015. Go to File, then New Project and select ASP.NET Web Application. Give the project a name “TechTalkers” and hit ok. In the template screen, under ASP.NET 5 preview templates, select Web Application and hit OK. The template follows ASP.NET MVC pattern for structuring your web app. Also contains individual user account login/logout functionality built in it.
Project Structure
The project structure is very simple. You have your .cshtml (.cshtml extension is used for Razor Views. Razor enables you to write C# code inside your html file) files in the Views folder. All your data transfer objects/POCOs (plain old class object) resides in the Models folder. And the controllers resides in the Controllers folder. Controllers are used to route any incoming request to a specific business logic block in your app. Other than these, we have Services and Migrations folders. In Services folder we have files to configure third party services like Email and SMS. In Migrations folder we have snapshots of our database migraTion history. These get generated by Entity Frameworks.
Now, let’s run the project. Click on the play icon (select your favourite browser from browse with… option).
You will see a Registration and a Login page along with three other pages i.e. Home, About and Contact pages. User can register himself into the system using the Registration page and start using the application. Once you register yourself, all your credential info will be saved to a local database. Connection string of the local database can be found in the config.json file.
- {
- "AppSettings": {
- "SiteTitle": "TechTalkers.Web"
- },
- "Data": {
- "DefaultConnection": {
- "ConnectionString": "Server=(localdb)\\mssqllocaldb;Database=aspnet5-TechTalkers.Web-6bfb60b0-3080-4310-b46c-9dc1331c9bfa;Trusted_Connection=True;MultipleActiveResultSets=true"
- }
- }
- }
Our system is a little bit different from this architecture. In our system only one user can login/logout into and out of the system. He/She is of course an admin. We are going to explicitly create an admin user and place it right into our database. So, there is no need for a Registration page. Again there is no need for the About and Contact pages either.
Creating Object Models
Okay let’s get our hands dirty. We need to create two model classes that will be mapped to two different tables in our database using Entity Framework. One of the table is for holding info about speakers and next one is holding info about the speech topic associated with a speaker.
Now, right click on the Models folder and add two classes called Speaker and Topic. The model classes looks like the following code snippet:
Topic.cs
- public class Topic
- {
- public Guid TopicId { get; set; }
- public string Title { get; set; }
- }
- public class Topic
- {
- public Guid TopicId { get; set; }
- public string Title { get; set; }
- public Guid SpeakerId { get; set; }
- public Speaker Speaker { get; set; }
- }
Object Relational Mapping with Entity FrameworkWe have our models, now we have to map those to our local database tables. Entity framework going to help us achieving that. In the IdentityModels.cs class add two properties of type DbSet namely Speakers and Topics.
- public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
- {
- public DbSet<Speaker> Speakers { get; set; }
- public DbSet<Topic> Topics { get; set; }
- }
We would have used the DbContext class instead of IdentityDbContext if we wanted to only generate our Topic and Speaker tables. But IdentityDbContext helps us in generating tables to store information such as user credentials, user roles etc. in addition with other database tables. Okay now rebuild the project. To have these two new tables in our database we have to run a migration command. Let’s do it. Open up your command prompt and change directory to your project folder and run the following command:
- dnvm use 1.0.0-beta5
- dnx . ef migration add AddedSpeakerTopicModels
- dnx . ef migration apply
Running those command will produce a new migration file under Migrations folder with a timestamp added to the front. It is the snapshot of the current database structure.
Next we will add controllers to interact with the Speaker and database Table.
Adding Speaker Controllers and ViewsRight click on the Controllers folder, then select add new item and add a MVC controller class; name it SpeakerController.
The controller class only contains an Index() method which returns an action result.
- public IActionResult Index()
- {
- return View();
- }
Time to add the index view for the speaker controller's Index() action. Index view will show a table that shows all the Speakers added into the system and will also contain a link to add a new speaker in to the system. Add a new folder under Views folder named Speaker. Right click on the Speaker folder and select add new item and select MVC View Page. Give it the name Index since it is a view for the Index() action.
Copy and Paste the following mark-up
- @model IEnumerable<TechTalkers.Web.Models.Speaker>
- @{
- ViewBag.Title = "Speakers";
- }
- <h2>@ViewBag.Title</h2>
- <p>
- <a asp-action="Add">Add a new speaker</a>
- </p>
- <table class="table">
- <tr>
- <th>
- @Html.DisplayNameFor(model => model.Name)
- </th>
- <th>
- @Html.DisplayNameFor(model => model.Bio)
- </th>
- <th></th>
- </tr>
- @foreach (var item in Model)
- {
- <tr>
- <td>
- @Html.DisplayFor(modelItem => item.Name)
- </td>
- <td>
- @Html.DisplayFor(modelItem => item.Bio)
- </td>
- <td>
- <a asp-action="Detail" asp-route-speakerId="@item.SpeakerId">Detail</a> |
- <a asp-action="Edit" asp-route-speakerId="@item.SpeakerId">Edit</a> |
- <a asp-action="Delete" asp-route-speakerId="@item.SpeakerId">Delete</a>
- </td>
- </tr>
- }
- </table>
- public class SpeakerController : Controller
- {
- ApplicationDbContext _db;
- public SpeakerController(ApplicationDbContext db)
- {
- _db = db;
- }
- // GET: /<controller>/
- public IActionResult Index()
- {
- var speakers = _db.Speakers;
- return View(speakers);
- }
- }
We’ve Add action attached to the add a speaker link. Let’s add two action controller for it.
- [HttpGet]
- public IActionResult Add()
- {
- return View();
- }
- [HttpPost]
- public IActionResult Add(Speaker speaker)
- {
- _db.Speakers.Add(speaker);
- _db.SaveChanges();
- return RedirectToAction("Index");
- }
- @model TechTalkers.Web.Models.Speaker
- @{
- ViewBag.Title = "Add a Speaker";
- }
- <h2>@ViewBag.Title</h2>
- <div>
- <form asp-controller="Speaker" asp-action="Add" method="post">
- <div class="form-group">
- <label asp-for="Name"></label>
- <input asp-for="Name" class="form-control" placeholder="name" />
- </div>
- <div class="form-group">
- <label asp-for="Bio"></label>
- <textarea asp-for="Bio" class="form-control" rows=5 placeholder="bio"></textarea>
- </div>
- <input type="submit" class="btn btn-default" value="Add" />
- </form>
- </div>
Creating Detail view and its controller is also easy. From the Index view we pass the speakerId to our detail view.
- <a asp-action="Detail" asp-route-speakerId="@item.SpeakerId">Detail</a>
- [HttpGet]
- public IActionResult Detail(Guid speakerId)
- {
- var id = RouteData.Values["speakerId"];
- Speaker speaker = _db.Speakers.FirstOrDefault(s => s.SpeakerId == speakerId);
- return View(speaker);
- }
- @model TechTalkers.Web.Models.Speaker
- @{
- ViewBag.Title = "Details";
- }
- <h2>@ViewBag.Title</h2>
- <div>
- <dl class="dl-horizontal">
- <dt>@Html.DisplayNameFor(model => model.Name)</dt>
- <dd>@Html.DisplayFor(model => model.Name)</dd>
- <dt>@Html.DisplayNameFor(model => model.Bio)</dt>
- <dd>@Html.DisplayFor(model => model.Bio)</dd>
- </dl>
- </div>
Like the add view we’ve two controllers for the Edit view. One of these exactly looks like the Detail controller because we have to pass the selected speaker to the Edit view to have the previous values for the input field. The second controller is quite similar to the second Add controller except that we update the speaker instead of adding it to the database. Here goes the two controllers.
- [HttpGet]
- public IActionResult Edit(Guid speakerId)
- {
- Speaker speaker = _db.Speakers.FirstOrDefault(s => s.SpeakerId == speakerId);
- return View(speaker);
- }
- [HttpPost]
- public IActionResult Edit(Speaker speaker)
- {
- _db.Speakers.Update(speaker);
- _db.SaveChanges();
- return RedirectToAction("Index");
- }
- @model TechTalkers.Web.Models.Speaker
- @{
- ViewBag.Title = "Edit Speaker";
- }
- <h2>@ViewBag.Title</h2>
- <div>
- <form asp-controller="Speaker" asp-action="Edit" method="post" asp-route-speakerId="@Model.SpeakerId">
- <div class="form-group">
- <label asp-for="Name"></label>
- <input asp-for="Name" class="form-control" />
- </div>
- <div class="form-group">
- <label asp-for="Bio"></label>
- <textarea asp-for="Bio" rows=3 class="form-control"></textarea>
- </div>
- <input type="submit" class="btn btn-default" value="Edit" />
- </form>
- </div>
- [HttpGet]
- public IActionResult Delete(Guid speakerId)
- {
- Speaker speaker = _db.Speakers.FirstOrDefault(s => s.SpeakerId == speakerId);
- return View(speaker);
- }
- public IActionResult DeleteConfirmed(Guid speakerId)
- {
- Speaker speaker = _db.Speakers.FirstOrDefault(s => s.SpeakerId == speakerId);
- _db.Speakers.Remove(speaker);
- _db.SaveChanges();
- return RedirectToAction("Index");
- }
- @model TechTalkers.Web.Models.Speaker
- @{
- ViewBag.Title = "Delete";
- }
- <h2>@ViewBag.Title</h2>
- <div>
- <dl class="dl-horizontal">
- <dt>@Html.DisplayNameFor(model => model.Name)</dt>
- <dd>@Html.DisplayFor(model => model.Name)</dd>
- <dt>@Html.DisplayNameFor(model => model.Bio)</dt>
- <dd>@Html.DisplayFor(model => model.Bio)</dd>
- </dl>
- <form asp-controller="Speaker" asp-action="DeleteConfirmed" asp-route-speakerId="@Model.SpeakerId">
- <input type="submit" class="btn btn-default" value="Delete" />
- </form>
- </div>
Adding Topic Controllers and Views Most of the work is almost same as creating the Speaker controllers and views. Additional works requires us to put a dropdown list of available speakers for selecting one associated with a topic. Let’s do it.
As before create a controller and name it TopicController. Create an ApplicationDbContext instance in the constructor. Create the Index() controller as before except now pass the list of topics to the view. Don’t forget to include the navigational property we talked about.
- public IActionResult Index()
- {
- var topics = _db.Topics.Include(s=>s.Speaker);
- return View(topics);
- }
- @model IEnumerable<TechTalkers.Web.Models.Topic>
- @{
- ViewBag.Title = "Topics";
- }
- <h2>@ViewBag.Title</h2>
- <p>
- <a asp-action="Add">Add a new topic</a>
- </p>
- <table class="table">
- <tr>
- <th>
- @Html.DisplayNameFor(model => model.Title)
- </th>
- <th>
- @Html.DisplayNameFor(model => model.Speaker)
- </th>
- <th></th>
- </tr>
- @foreach (var item in Model)
- {
- <tr>
- <td>
- @Html.DisplayFor(modelItem => item.Title)
- </td>
- <td>
- @Html.DisplayFor(modelItem => item.Speaker.Name)
- </td>
- <td>
- <a asp-action="Detail" asp-route-topicId="@item.TopicId">Detail</a> |
- <a asp-action="Edit" asp-route-topicId="@item.TopicId">Edit</a> |
- <a asp-action="Delete" asp-route-topicId="@item.TopicId">Delete</a>
- </td>
- </tr>
- }
- </table>
- [HttpGet]
- public IActionResult Add()
- {
- Speakers = GetAllSpeakers();
- ViewBag.Speakers = Speakers;
- return View();
- }
- private IEnumerable<SelectListItem> GetAllSpeakers()
- {
- return _db.Speakers.ToList().Select(speaker => new SelectListItem
- {
- Text = speaker.Name,
- Value = speaker.SpeakerId.ToString(),
- });
- }
- @model TechTalkers.Web.Models.Topic
- @{
- ViewBag.Title = "Add a Topic";
- }
- <h2>@ViewBag.Title</h2>
- <div>
- <form asp-controller="Topic" asp-action="Add" method="post">
- <div class="form-group">
- <label asp-for="Title"></label>
- <input asp-for="Title" class="form-control" placeholder="title" />
- </div>
- <div class="form-group">
- <select asp-for="SpeakerId" asp-items="@ViewBag.Speakers" class="form-control"></select>
- </div>
- <input type="submit" class="btn btn-default" value="Add"/>
- </form>
- </div>
- [HttpPost]
- public IActionResult Add(Topic topic)
- {
- _db.Topics.Add(topic);
- _db.SaveChanges();
- return RedirectToAction("Index");
- }
- @model TechTalkers.Web.Models.Topic
- @{
- ViewBag.Title = "Details";
- }
- <h2>@ViewBag.Title</h2>
- <div>
- <dl class="dl-horizontal">
- <dt>@Html.DisplayNameFor(model => model.Title)</dt>
- <dd>@Html.DisplayFor(model => model.Title)</dd>
- <dt>@Html.DisplayNameFor(model => model.Speaker)</dt>
- <dd>@Html.DisplayFor(model => model.Speaker.Name)</dd>
- </dl>
- </div>
- [HttpGet]
- public IActionResult Detail(Guid topicId)
- {
- Topic topic = _db.Topics.Include(s=>s.Speaker).FirstOrDefault(t => t.TopicId == topicId);
- return View(topic);
- }
- @model TechTalkers.Web.Models.Topic
- @{
- ViewBag.Title = "Edit Topic";
- }
- <h2>@ViewBag.Title</h2>
- <div>
- <form asp-controller="Topic" asp-action="Edit" method="post" asp-route-topicId="@Model.TopicId">
- <div class="form-group">
- <label asp-for="Title"></label>
- <input asp-for="Title" class="form-control" />
- </div>
- <div class="form-group">
- <label asp-for="Speaker"></label>
- <select asp-for="SpeakerId" asp-items="@ViewBag.Speakers" class="form-control"></select>
- </div>
- <input type="submit" class="btn btn-default" value="Edit" />
- </form>
- </div>
- [HttpGet]
- public IActionResult Edit(Guid topicId)
- {
- Speakers = GetAllSpeakers();
- ViewBag.Speakers = Speakers;
- Topic topic = _db.Topics.Include(s => s.Speaker).FirstOrDefault(t => t.TopicId == topicId);
- return View(topic);
- }
- [HttpPost]
- public IActionResult Edit(Topic topic)
- {
- _db.Topics.Update(topic);
- _db.SaveChanges();
- return RedirectToAction("Index");
- }
- @model TechTalkers.Web.Models.Topic
- @{
- ViewBag.Title = "Delete";
- }
- <h2>@ViewBag.Title</h2>
- <div>
- <dl class="dl-horizontal">
- <dt>@Html.DisplayNameFor(model => model.Title)</dt>
- <dd>@Html.DisplayFor(model => model.Title)</dd>
- <dt>@Html.DisplayNameFor(model => model.Speaker)</dt>
- <dd>@Html.DisplayFor(model => model.Speaker.Name)</dd>
- </dl>
- <form asp-controller="Topic" asp-action="DeleteConfirmed" asp-route-topicId="@Model.TopicId">
- <input type="submit" class="btn btn-default" value="Delete" />
- </form>
- </div>
- [HttpGet]
- public IActionResult Delete(Guid topicId)
- {
- Topic topic = _db.Topics.Include(s => s.Speaker).FirstOrDefault(t => t.TopicId == topicId);
- return View(topic);
- }
- public IActionResult DeleteConfirmed(Guid topicId)
- {
- Topic topic = _db.Topics.FirstOrDefault(t => t.TopicId == topicId);
- _db.Topics.Remove(topic);
- _db.SaveChanges();
- return RedirectToAction("Index");
- }
Everything looks good.
Adding Admin AuthenticationAs we have already said our system will consist of one single user who is ultimately an admin. Only an admin can add/remove/edit Topics and Speakers. So, there is no need for a registration view for outside users. You can remove most of the AccountController code but I’m going to comment out for now. Commenting out code is good rather than deleting. You may need that block in future.
- public IActionResult Register() { ... }
- public async Task<IActionResult> Register(RegisterViewModel model) { ... }
We created a class which is responsible for initializing the database with a user in admin role. Here is what it looks like
- public class DatabaseInitializer : IDatabaseInitializer
- {
- private readonly UserManager<ApplicationUser> _userManager;
- private readonly RoleManager<IdentityRole> _roleManager;
- public DatabaseInitializer(UserManager<ApplicationUser> userManager, RoleManager<IdentityRole> roleManager)
- {
- _userManager = userManager;
- _roleManager = roleManager;
- }
- public async Task CreateUserAndRoleAsync()
- {
- var user = await _userManager.FindByEmailAsync("fiyazhasan@gmail.com");
- if (user == null)
- {
- user = new ApplicationUser {
- UserName = "fiyazhasan@gmail.com",
- Email = "fiyazhasan@gmail.com"
- };
- await _userManager.CreateAsync(user, "@Fizz1234");
- }
- var role = await _roleManager.FindByNameAsync("admin");
- if (role == null) {
- role = new IdentityRole {
- Name = "admin"
- };
- await _roleManager.CreateAsync(role);
- }
- await _userManager.AddToRoleAsync(user, "admin");
- }
- }
- public interface IDatabaseInitializer
- {
- Task CreateUserAndRoleAsync();
- }
- public class ApplicationUser : IdentityUser
- {
- }
- public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
- {
- public DbSet<Speaker> Speakers { get; set; }
- public DbSet<Topic> Topics { get; set; }
- protected override void OnModelCreating(ModelBuilder builder)
- {
- base.OnModelCreating(builder);
- // Customize the ASP.NET Identity model and override the defaults if needed.
- // For example, you can rename the ASP.NET Identity table names and more.
- // Add your customizations after calling base.OnModelCreating(builder);
- }
- }
Now we have to inject this initializer class in to the ConfigureServices method found in the startup class. Now to run the initializer when the app runs we call the CreateUserAndRoleAsync() method found in the same class where the ConfigureServices() resides. Here is the final look of the startup class.
- public void ConfigureServices(IServiceCollection services)
- {
- // Add Entity Framework services to the services container.
- services.AddEntityFramework()
- .AddSqlServer()
- .AddDbContext<ApplicationDbContext>(options =>
- options.UseSqlServer(Configuration["Data:DefaultConnection:ConnectionString"]));
- // Add Identity services to the services container.
- services.AddIdentity<ApplicationUser, IdentityRole>()
- .AddEntityFrameworkStores<ApplicationDbContext>()
- .AddDefaultTokenProviders();
- // Configure the options for the authentication middleware.
- // You can add options for Google, Twitter and other middleware as shown below.
- // For more information see http://go.microsoft.com/fwlink/?LinkID=532715
- services.Configure<FacebookAuthenticationOptions>(options =>
- {
- options.AppId = Configuration["Authentication:Facebook:AppId"];
- options.AppSecret = Configuration["Authentication:Facebook:AppSecret"];
- });
- services.Configure<MicrosoftAccountAuthenticationOptions>(options =>
- {
- options.ClientId = Configuration["Authentication:MicrosoftAccount:ClientId"];
- options.ClientSecret = Configuration["Authentication:MicrosoftAccount:ClientSecret"];
- });
- // Add MVC services to the services container.
- services.AddMvc();
- // Uncomment the following line to add Web API services which makes it easier to port Web API 2 controllers.
- // You will also need to add the Microsoft.AspNet.Mvc.WebApiCompatShim package to the 'dependencies' section of project.json.
- // services.AddWebApiConventions();
- // Register application services.
- services.AddTransient<IEmailSender, AuthMessageSender>();
- services.AddTransient<ISmsSender, AuthMessageSender>();
- services.AddTransient<IDatabaseInitializer,DatabaseInitializer>();
- }
- // Configure is called after ConfigureServices is called.
- public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory, IDatabaseInitializer databaseInitializer)
- {
- loggerFactory.MinimumLevel = LogLevel.Information;
- loggerFactory.AddConsole();
- // Configure the HTTP request pipeline.
- // Add the following to the request pipeline only in development environment.
- if (env.IsDevelopment())
- {
- app.UseBrowserLink();
- app.UseErrorPage(ErrorPageOptions.ShowAll);
- app.UseDatabaseErrorPage(DatabaseErrorPageOptions.ShowAll);
- }
- else
- {
- // Add Error handling middleware which catches all application specific errors and
- // sends the request to the following path or controller action.
- app.UseErrorHandler("/Home/Error");
- }
- // Add static files to the request pipeline.
- app.UseStaticFiles();
- // Add cookie-based authentication to the request pipeline.
- app.UseIdentity();
- // Add authentication middleware to the request pipeline. You can configure options such as Id and Secret in the ConfigureServices method.
- // For more information see http://go.microsoft.com/fwlink/?LinkID=532715
- // app.UseFacebookAuthentication();
- // app.UseGoogleAuthentication();
- // app.UseMicrosoftAccountAuthentication();
- // app.UseTwitterAuthentication();
- // Add MVC to the request pipeline.
- app.UseMvc(routes =>
- {
- routes.MapRoute(
- name: "default",
- template: "{controller=Home}/{action=Index}/{id?}");
- // Uncomment the following line to add a route for porting Web API 2 controllers.
- // routes.MapWebApiRoute("DefaultApi", "api/{controller}/{id?}");
- });
- databaseInitializer.CreateUserAndRoleAsync();
- }
- public class SpeakerController : Controller
- {
- readonly ApplicationDbContext _db;
- public SpeakerController(ApplicationDbContext db)
- {
- _db = db;
- }
- // GET: /<controller>/
- public IActionResult Index()
- {
- var speakers = _db.Speakers;
- return View(speakers);
- }
- [HttpGet]
- [Authorize(Roles = "admin")]
- public IActionResult Add()
- {
- return View();
- }
- [HttpPost]
- [Authorize(Roles = "admin")]
- public IActionResult Add(Speaker speaker)
- {
- _db.Speakers.Add(speaker);
- _db.SaveChanges();
- return RedirectToAction("Index");
- }
- [HttpGet]
- [Authorize(Roles = "admin")]
- public IActionResult Detail(Guid speakerId)
- {
- Speaker speaker = _db.Speakers.FirstOrDefault(s => s.SpeakerId == speakerId);
- return View(speaker);
- }
- [HttpGet]
- [Authorize(Roles = "admin")]
- public IActionResult Edit(Guid speakerId)
- {
- Speaker speaker = _db.Speakers.FirstOrDefault(s => s.SpeakerId == speakerId);
- return View(speaker);
- }
- [HttpPost]
- [Authorize(Roles = "admin")]
- public IActionResult Edit(Speaker speaker)
- {
- _db.Speakers.Update(speaker);
- _db.SaveChanges();
- return RedirectToAction("Index");
- }
- [HttpGet]
- [Authorize(Roles = "admin")]
- public IActionResult Delete(Guid speakerId)
- {
- Speaker speaker = _db.Speakers.FirstOrDefault(s => s.SpeakerId == speakerId);
- return View(speaker);
- }
- [Authorize(Roles = "admin")]
- public IActionResult DeleteConfirmed(Guid speakerId)
- {
- Speaker speaker = _db.Speakers.FirstOrDefault(s => s.SpeakerId == speakerId);
- _db.Speakers.Remove(speaker);
- _db.SaveChanges();
- return RedirectToAction("Index");
- }
- }
Time to remove the Registration and Login links from the Layout page. Go to Views, Shared, then_LoginPartial and comment out these lines.
- else
- {
- <ul class="nav navbar-nav navbar-right">
- <li><a asp-action="Register" asp-controller="Account">Register</a></li>
- <li><a asp-action="Login" asp-controller="Account">Log in</a></li>
- </ul>
- }
Before leaving just add the Speaker and Topic view link in the _Layout view. Replace the About and Contact Link with these two,
- <li><a asp-controller="Topic" asp-action="Index">Topic</a></li>
- <li><a asp-controller="Speaker" asp-action="Index">Speaker</a></li>
No comments:
Post a Comment