diff --git a/.gitignore b/.gitignore new file mode 100755 index 000000000..0ca27f04e --- /dev/null +++ b/.gitignore @@ -0,0 +1,234 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# 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 +# TODO: 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 + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Microsoft Azure ApplicationInsights config file +ApplicationInsights.config + +# Windows Store app package directory +AppPackages/ +BundleArtifacts/ + +# 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 +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# 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 + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# 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 + +# FAKE - F# Make +.fake/ diff --git a/README.markdown b/README.markdown index f93d526ae..073652519 100644 --- a/README.markdown +++ b/README.markdown @@ -33,8 +33,35 @@ There are many ways that this application could be built; we ask that you build Please modify `README.md` to add: -1. Instructions on how to build/run your application -1. A paragraph or two about what you are particularly proud of in your implementation, and why. +### Instructions to Build/Run application + +I developed this on Ubuntu 16.04 using dotnet core (using Visual Studio Code if you're interested) + +See the following for instructions on how to install dotnet core on Ubuntu. There are also Mac instructions as well as other Linux distro instructions. + +https://www.microsoft.com/net/core#linuxubuntu + +Complete step 1 to set up the dotnet core apt-get feed + +Complete step 2 to install dotnet core + +Clone the repository and then navigate inside the se-challenge-expenses folder. + +Run the command 'dotnet restore' + +Navigate to the se-challenge-expenses/src/aspnetcoreapp + +Run the command 'dotnet run' + +The 'dotnet run' command should give a url to navigate to. Usually it is http://localhost:5000 + +This project uses SQLite, .NET Core Platform, ASP.NET Core MVC framework, and Dapper.NET micro ORM + +### Implementation thoughts + +This was an interesting project for me. I'm a .NET developer but I haven't used the .NET core platform or the ASP.NET Core web framework before. Most of my time was spent setting things up on linux and getting to know the new platform. Once I had things working it was a case of searching around for where all my familiar libraries had been moved to. So the part of this project I enjoyed the most was getting the new stack up and running on linux. + +The ASP.NET core (web framework) has a well defined template for starting a project and separating the concerns of the application. I chose to use a simple model, Expense, to represent the data going into the database. I used a simple viewmodel, ExpenseReportViewModel, to represent the data that is queried from the database and displayed in the expense report. If the project expanded I might think about breaking up the Expense model. I would break up the model to represent an Employee, Tax, and Expense but if all we're doing is calculating the expense report there's no point in adding unnecessary joins to the query. ## Submission Instructions diff --git a/global.json b/global.json new file mode 100644 index 000000000..5daa83324 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "projects":[ + "src", + "test" + ] +} diff --git a/src/aspnetcoreapp/Controllers/HomeController.cs b/src/aspnetcoreapp/Controllers/HomeController.cs new file mode 100755 index 000000000..b1f0acbd1 --- /dev/null +++ b/src/aspnetcoreapp/Controllers/HomeController.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using WebApplication.Services; +using WebApplication.Data; + +namespace WebApplication.Controllers +{ + public class HomeController : Controller + { + + private readonly ICsvImporter _csvImporter; + private readonly IExpenseRepository _expenseRepository; + + public HomeController(ICsvImporter csvImporter, IExpenseRepository expenseRepository) + { + _csvImporter = csvImporter; + _expenseRepository = expenseRepository; + } + + public IActionResult Index() + { + // The view being returned is calculated based on the name of the + // controller (Home) and the name of the action method (Index). + // So in this case, the view returned is /Views/Home/Index.cshtml. + return View(); + } + + [HttpPost] + public ActionResult Upload(IFormFile file) + { + try + { + var expenses = _csvImporter.ReadCsvFile(file); + _expenseRepository.SaveExpenses(expenses); + } + catch (CsvImportException e) + { + ViewData["error"] = e.Message; + return View("Index"); + } + catch(Exception e) + { + ViewData["error"] = e.Message; + return View("Index"); + } + return RedirectToAction("Expense"); + } + + public IActionResult Expense() + { + // Creates a model and passes it on to the view. + var expenseViewModels = _expenseRepository.GetExpenseReport(); + + return View(expenseViewModels); + } + } +} diff --git a/src/aspnetcoreapp/Data/DapperBaseRepo.cs b/src/aspnetcoreapp/Data/DapperBaseRepo.cs new file mode 100644 index 000000000..98ab19f5e --- /dev/null +++ b/src/aspnetcoreapp/Data/DapperBaseRepo.cs @@ -0,0 +1,27 @@ +using Microsoft.Data.Sqlite; +using System.Data; +using Microsoft.AspNetCore.Hosting; + +namespace WebApplication.Data + { + public class DapperBaseRepo + { + private readonly IHostingEnvironment _hostingEnvironment; + + public DapperBaseRepo (IHostingEnvironment hostingEnvironment) + { + _hostingEnvironment = hostingEnvironment; + } + public string DbFile + { + get { return _hostingEnvironment.ContentRootPath + "/ImportDb.sqlite"; } + } + + public IDbConnection SimpleDbConnection() + { + return new SqliteConnection("Data Source=" + DbFile); + } + } + } + + diff --git a/src/aspnetcoreapp/Data/ExpenseRepository.cs b/src/aspnetcoreapp/Data/ExpenseRepository.cs new file mode 100644 index 000000000..01bbf7c95 --- /dev/null +++ b/src/aspnetcoreapp/Data/ExpenseRepository.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Dapper; +using WebApplication.Models; +using System.Linq; +using Microsoft.AspNetCore.Hosting; + +namespace WebApplication.Data +{ + public class ExpenseRepository : DapperBaseRepo, IExpenseRepository + { + public ExpenseRepository(IHostingEnvironment hostingEnvironment) : base(hostingEnvironment) + { + } + + private void CreateDatabase() + { + using (var cnn = SimpleDbConnection()) + { + cnn.Open(); + cnn.Execute(createExpenseTable); + } + } + + public IList GetExpenseReport() + { + if (!File.Exists(DbFile)) + { + return new List(); + } + IList entities; + + using (var cnn = SimpleDbConnection()) + { + cnn.Open(); + entities = cnn.Query (expenseSumSql).ToList(); + } + return entities; + } + + public void SaveExpenses(IList expenses) + { + if (!File.Exists(DbFile)) + { + CreateDatabase(); + } + + using (var cnn = SimpleDbConnection()) + { + cnn.Open(); + foreach (var expense in expenses) + { + int id = cnn.Query( + expenseInsertSql, expense).First(); + } + } + + } + + private const string createExpenseTable = @"create table Expense + ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + Date DATETIME not null, + Category TEXT not null, + EmployeeName DATETIME not null, + EmployeeAddress TEXT not null, + ExpenseDescription TEXT not null, + PreTaxAmount REAL not null, + TaxName TEXT not null, + TaxAmount REAL not null + )"; + + private const string expenseSumSql = @"select SUM(PreTaxAmount) as PreTaxSum, + SUM (TaxAmount) as TaxSum, + SUM (PreTaxAmount + TaxAmount) as ExpenseSum, + strftime(""%Y-%m"", Date) as Month + from expense group by strftime(""%Y-%m"", Date);"; + + private const string expenseInsertSql = @"INSERT INTO Expense + ( Date, Category, EmployeeName, EmployeeAddress, ExpenseDescription, PreTaxAmount, TaxName, TaxAmount) VALUES + ( @Date, @Category, @EmployeeName, @EmployeeAddress, @ExpenseDescription, @PreTaxAmount, @TaxName, @TaxAmount); + select last_insert_rowid()"; + } +} diff --git a/src/aspnetcoreapp/Data/IExpenseRepository.cs b/src/aspnetcoreapp/Data/IExpenseRepository.cs new file mode 100644 index 000000000..b1a9efce3 --- /dev/null +++ b/src/aspnetcoreapp/Data/IExpenseRepository.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using WebApplication.Models; + +namespace WebApplication.Data +{ + public interface IExpenseRepository + { + IList GetExpenseReport(); + void SaveExpenses(IList imports); + } +} diff --git a/src/aspnetcoreapp/Models/Expense.cs b/src/aspnetcoreapp/Models/Expense.cs new file mode 100644 index 000000000..5fc86df5d --- /dev/null +++ b/src/aspnetcoreapp/Models/Expense.cs @@ -0,0 +1,18 @@ +using System; + +namespace WebApplication.Models +{ + public class Expense + { + //date,category,employee name,employee address,expense description,pre-tax amount,tax name,tax amount + // could be broken into Employee, Tax etc + public DateTime Date { get; set; } + public string Category { get; set; } + public string EmployeeName { get; set; } + public string EmployeeAddress { get; set; } + public string ExpenseDescription { get; set; } + public double PreTaxAmount { get; set; } + public string TaxName { get; set; } + public double TaxAmount { get; set; } + } +} diff --git a/src/aspnetcoreapp/Models/ExpenseReportViewModel.cs b/src/aspnetcoreapp/Models/ExpenseReportViewModel.cs new file mode 100644 index 000000000..c19f31056 --- /dev/null +++ b/src/aspnetcoreapp/Models/ExpenseReportViewModel.cs @@ -0,0 +1,10 @@ +namespace WebApplication.Models +{ + public class ExpenseReportViewModel + { + public string Month { get; set; } + public double PreTaxSum { get; set; } + public double TaxSum { get; set; } + public double ExpenseSum { get; set; } + } +} diff --git a/src/aspnetcoreapp/Program.cs b/src/aspnetcoreapp/Program.cs new file mode 100755 index 000000000..4a2bf6ecc --- /dev/null +++ b/src/aspnetcoreapp/Program.cs @@ -0,0 +1,20 @@ +using System.IO; +using Microsoft.AspNetCore.Hosting; + +namespace WebApplication +{ + public class Program + { + public static void Main(string[] args) + { + var host = new WebHostBuilder() + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .UseStartup() + .Build(); + + host.Run(); + } + } +} diff --git a/src/aspnetcoreapp/Services/Exceptions.cs b/src/aspnetcoreapp/Services/Exceptions.cs new file mode 100644 index 000000000..ee6b94ed6 --- /dev/null +++ b/src/aspnetcoreapp/Services/Exceptions.cs @@ -0,0 +1,9 @@ +using System; + +namespace WebApplication.Services +{ + public class CsvImportException : Exception + { + public CsvImportException(string message) : base(message) { } + } +} diff --git a/src/aspnetcoreapp/Services/ICsvImporter.cs b/src/aspnetcoreapp/Services/ICsvImporter.cs new file mode 100644 index 000000000..89520fe04 --- /dev/null +++ b/src/aspnetcoreapp/Services/ICsvImporter.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using WebApplication.Models; +using Microsoft.AspNetCore.Http; +using System.IO; +using System.Text.RegularExpressions; +using System.Globalization; +using System.Linq; + +namespace WebApplication.Services +{ + public interface ICsvImporter + { + IList ReadCsvFile(IFormFile file); + } + + public class CsvImporter : ICsvImporter + { + private const int columnNumber = 8; + + private const string expenseConvertError = "Error reading expense column data on line: {0}"; + private const string preTaxAmountConvertError = "Error reading pre tax amount column data on line: {0}"; + private const string taxtAmountConvertError = "Error reading tax amount on line: {0}"; + private const string invalidNumberOfColumnsError = "The csv file does not contain the correct number of columns"; + public IList ReadCsvFile (IFormFile file) + { + var csvImports = new List(); + int lineNumber = 0; + bool skipFirstLine = true; + + + using (var reader = new StreamReader(file.OpenReadStream())) + { + while (!reader.EndOfStream) + { + + string line = reader.ReadLine(); + lineNumber += 1; + if (skipFirstLine) + { + skipFirstLine = false; + continue; + } + var csvImport = CreateCsvImport(line, lineNumber); + csvImports.Add(csvImport); + } + } + + return csvImports; + } + + public Expense CreateCsvImport(string line, int lineNumber) + { + // http://stackoverflow.com/a/25756010 + var fields = new Regex("((?<=\")[^\"]*(?=\"(,|$)+)|(?<=,|^)[^,\"]*(?=,|$))").Matches(line) + .Cast() + .Select(m => m.Value) + .ToArray(); + + if (fields.Length != columnNumber) + { + throw new CsvImportException(invalidNumberOfColumnsError); + } + + + var csvImport = new Expense(); + + var expenseDate = new DateTime(); + var preTaxAmount = new double(); + var taxAmount = new double(); + + if (DateTime.TryParse(fields[0].Replace(",", ""), out expenseDate)) + { + csvImport.Date = expenseDate; + } + else + { + throw new CsvImportException(string.Format(expenseConvertError, lineNumber)); + } + + csvImport.Category = fields[1]; + csvImport.EmployeeName = fields[2]; + csvImport.EmployeeAddress = fields[3]; + csvImport.ExpenseDescription = fields[4]; + + if (Double.TryParse(fields[5], NumberStyles.Any, CultureInfo.InvariantCulture, out preTaxAmount)) + { + csvImport.PreTaxAmount = preTaxAmount; + } + else + { + throw new CsvImportException(string.Format(preTaxAmountConvertError, lineNumber)); + } + + csvImport.TaxName = fields[6]; + + if (Double.TryParse(fields[7], NumberStyles.Any, + CultureInfo.InvariantCulture, out taxAmount)) + { + csvImport.TaxAmount = taxAmount; + } + else + { + throw new CsvImportException(string.Format(taxtAmountConvertError, lineNumber)); + } + + return csvImport; + } + } +} diff --git a/src/aspnetcoreapp/Startup.cs b/src/aspnetcoreapp/Startup.cs new file mode 100755 index 000000000..c097c45e6 --- /dev/null +++ b/src/aspnetcoreapp/Startup.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.IO; +using WebApplication.Data; +using WebApplication.Services; + +namespace WebApplication +{ + public class Startup + { + public Startup(IHostingEnvironment env) + { + var builder = new ConfigurationBuilder() + .SetBasePath(env.ContentRootPath) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) + .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true); + + if (env.IsDevelopment()) + { + // For more details on using the user secret store see https://go.microsoft.com/fwlink/?LinkID=532709 + builder.AddUserSecrets(); + } + + builder.AddEnvironmentVariables(); + Configuration = builder.Build(); + + DestoryDatabase(); + } + + public IConfigurationRoot Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + + services.AddMvc(); + + services.AddTransient(); + services.AddSingleton(); + + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) + { + loggerFactory.AddConsole(Configuration.GetSection("Logging")); + loggerFactory.AddDebug(); + + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + app.UseDatabaseErrorPage(); + app.UseBrowserLink(); + } + else + { + app.UseExceptionHandler("/Home/Error"); + } + app.UseDefaultFiles(); + app.UseStaticFiles(); + + // Add external authentication middleware below. To configure them please see https://go.microsoft.com/fwlink/?LinkID=532715 + + app.UseMvcWithDefaultRoute(); + } + + private void DestoryDatabase(){ + var dbFile = Directory.GetCurrentDirectory() + "/ImportDb.sqlite"; + if (File.Exists(dbFile)) + { + File.Delete(dbFile); + } + + } + } +} diff --git a/src/aspnetcoreapp/Views/Home/Expense.cshtml b/src/aspnetcoreapp/Views/Home/Expense.cshtml new file mode 100644 index 000000000..d1ec20aac --- /dev/null +++ b/src/aspnetcoreapp/Views/Home/Expense.cshtml @@ -0,0 +1,37 @@ +@model IList +@{ + ViewData["Title"] = "Expense"; + ViewData["Description"] = "The about page of my website"; +} + +

This is the Expense Report

+ +

Expenses

+@if (Model.Count() == 0) +{ +

No expense data

+} +else +{ + + + + + + + + @foreach (var report in Model) + { + + + + + + + } +
MonthPre Tax Amount($)Tax Amount($)Total Expense($)
@report.Month@report.PreTaxSum@report.TaxSum@report.ExpenseSum
+} + +@section Scripts { + @*Insert any script tags for this page here*@ +} diff --git a/src/aspnetcoreapp/Views/Home/Index.cshtml b/src/aspnetcoreapp/Views/Home/Index.cshtml new file mode 100644 index 000000000..b8126a54d --- /dev/null +++ b/src/aspnetcoreapp/Views/Home/Index.cshtml @@ -0,0 +1,26 @@ +@{ + ViewData["Title"] = "Home Page"; + ViewData["Description"] = "The home page of my website"; +} + +@if(ViewData["error"]!=null) + { + @if(!string.IsNullOrEmpty(ViewData["error"].ToString())) + { +

There was an error:

+

@ViewData["error"]

+ } + } + + +
+ + + + + +
+ +@section Scripts { + @*Insert any script tags for this page here*@ +} \ No newline at end of file diff --git a/src/aspnetcoreapp/Views/_Layout.cshtml b/src/aspnetcoreapp/Views/_Layout.cshtml new file mode 100644 index 000000000..9f9b43eb3 --- /dev/null +++ b/src/aspnetcoreapp/Views/_Layout.cshtml @@ -0,0 +1,36 @@ + + + + + @ViewData["Title"] - waveChallenge + + + + + +
+
+ +
+
+

@ViewData["Title"]

+ @RenderBody() +
+
+
+
+ © @DateTime.Now.Year - waveChallenge +
+
+ + + + + + \ No newline at end of file diff --git a/src/aspnetcoreapp/Views/_ViewImports.cshtml b/src/aspnetcoreapp/Views/_ViewImports.cshtml new file mode 100755 index 000000000..34be97a9b --- /dev/null +++ b/src/aspnetcoreapp/Views/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@using WebApplication +@using WebApplication.Models +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/aspnetcoreapp/Views/_ViewStart.cshtml b/src/aspnetcoreapp/Views/_ViewStart.cshtml new file mode 100755 index 000000000..e4f4f842e --- /dev/null +++ b/src/aspnetcoreapp/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "~/Views/_Layout.cshtml"; +} diff --git a/src/aspnetcoreapp/appsettings.json b/src/aspnetcoreapp/appsettings.json new file mode 100755 index 000000000..53b17ae04 --- /dev/null +++ b/src/aspnetcoreapp/appsettings.json @@ -0,0 +1,13 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Data Source=WebApplication.db" + }, + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/src/aspnetcoreapp/project.json b/src/aspnetcoreapp/project.json new file mode 100755 index 000000000..b67299179 --- /dev/null +++ b/src/aspnetcoreapp/project.json @@ -0,0 +1,112 @@ +{ + "userSecretsId": "aspnet-WebApplication-0799fe3e-6eaf-4c5f-b40e-7c6bfd5dfa9a", + + "dependencies": { + "Microsoft.NETCore.App": { + "version": "1.1.0", + "type": "platform" + }, + "Microsoft.AspNetCore.Diagnostics": "1.0.0", + "Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore": "1.0.0", + "Microsoft.AspNetCore.Mvc": "1.0.1", + "Dapper": "1.50.2", + "Microsoft.Data.Sqlite": "1.1.0", + "Microsoft.AspNetCore.Razor.Tools": { + "version": "1.0.0-preview2-final", + "type": "build" + }, + "Microsoft.AspNetCore.Routing": "1.0.1", + "Microsoft.AspNetCore.Server.IISIntegration": "1.0.0", + "Microsoft.AspNetCore.Server.Kestrel": "1.0.1", + "Microsoft.AspNetCore.StaticFiles": "1.0.0", + "Microsoft.EntityFrameworkCore.Sqlite": "1.0.1", + "Microsoft.EntityFrameworkCore.Tools": { + "version": "1.0.0-preview2-final", + "type": "build" + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": "1.0.0", + "Microsoft.Extensions.Configuration.Json": "1.0.0", + "Microsoft.Extensions.Configuration.UserSecrets": "1.0.0", + "Microsoft.Extensions.Logging": "1.0.0", + "Microsoft.Extensions.Logging.Console": "1.0.0", + "Microsoft.Extensions.Logging.Debug": "1.0.0", + "Microsoft.VisualStudio.Web.BrowserLink.Loader": "14.0.0", + "Microsoft.VisualStudio.Web.CodeGeneration.Tools": { + "version": "1.0.0-preview2-update1", + "type": "build" + }, + "Microsoft.VisualStudio.Web.CodeGenerators.Mvc": { + "version": "1.0.0-preview2-update1", + "type": "build" + } + }, + + "tools": { + "Microsoft.AspNetCore.Razor.Tools": { + "version": "1.0.0-preview2-final", + "imports": "portable-net45+win8+dnxcore50" + }, + "Microsoft.AspNetCore.Server.IISIntegration.Tools": { + "version": "1.0.0-preview2-final", + "imports": "portable-net45+win8+dnxcore50" + }, + "Microsoft.EntityFrameworkCore.Tools": { + "version": "1.0.0-preview2-final", + "imports": [ + "portable-net45+win8+dnxcore50", + "portable-net45+win8" + ] + }, + "Microsoft.Extensions.SecretManager.Tools": { + "version": "1.0.0-preview2-final", + "imports": "portable-net45+win8+dnxcore50" + }, + "Microsoft.VisualStudio.Web.CodeGeneration.Tools": { + "version": "1.0.0-preview2-final", + "imports": [ + "portable-net45+win8+dnxcore50", + "portable-net45+win8" + ] + } + }, + + "frameworks": { + "netcoreapp1.1": { + "imports": [ + "dotnet5.6", + "dnxcore50", + "portable-net45+win8" + ] + } + }, + + "buildOptions": { + "debugType": "portable", + "emitEntryPoint": true, + "preserveCompilationContext": true + }, + + "runtimeOptions": { + "configProperties": { + "System.GC.Server": true + } + }, + + "publishOptions": { + "include": [ + "wwwroot", + "**/*.cshtml", + "appsettings.json", + "Views", + "web.config" + ] + }, + + "scripts": { + "postpublish": [ "dotnet publish-iis --publish-folder %publish:OutputPath% --framework %publish:FullTargetFramework%" ] + }, + + "tooling": { + "defaultNamespace": "WebApplication" + } +} diff --git a/src/aspnetcoreapp/web.config b/src/aspnetcoreapp/web.config new file mode 100755 index 000000000..a8d667275 --- /dev/null +++ b/src/aspnetcoreapp/web.config @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/src/aspnetcoreapp/wwwroot/css/site-main.css b/src/aspnetcoreapp/wwwroot/css/site-main.css new file mode 100644 index 000000000..bcc08ad5c --- /dev/null +++ b/src/aspnetcoreapp/wwwroot/css/site-main.css @@ -0,0 +1,84 @@ +html, body { + height: 100%; + font: 16px/1.5 "Helvetica Neue",Helvetica,Arial,sans-serif; + margin: 0; +} + +.container { + max-width: 950px; + margin: 0 auto; + padding: 0 10px; + display: block; +} + +table{ + border-collapse: collapse; +} + +th, td { + border: 1px solid black; + width : 150px; + text-align: left; +} + +header { + background-color: #080808; +} + +nav a { + color: #9d9d9d; + text-decoration: none; +} + +nav ul { + list-style: none; + padding: 0.7em 0; + margin: 0; +} + +nav li { + display: inline-block; + margin: 0 1em; +} + + nav li:first-child { + font-size: 1.3em; + margin-left: 0; + } + +main { + padding: 2rem 0; + display: block; +} + +/* This ensures the footer is always located at the bottom of the page */ +.wrapper { + min-height: 100%; + /* equal to footer height */ + margin-bottom: -50px; +} + + .wrapper:after { + content: ""; + display: block; + } + + footer, .wrapper:after { + height: 50px; + } + + footer .container { + border-top: 1px solid #e1e1e1; + padding-top: 10px; + } + +/* Changes the layout of the header on small screens */ +@media only screen and (max-width: 640px) { + header ul { + text-align: center; + } + + header li:first-child { + display: block; + } +} \ No newline at end of file diff --git a/src/aspnetcoreapp/wwwroot/favicon.ico b/src/aspnetcoreapp/wwwroot/favicon.ico new file mode 100755 index 000000000..a3a799985 Binary files /dev/null and b/src/aspnetcoreapp/wwwroot/favicon.ico differ diff --git a/src/aspnetcoreapp/wwwroot/js/site.js b/src/aspnetcoreapp/wwwroot/js/site.js new file mode 100755 index 000000000..e069226a1 --- /dev/null +++ b/src/aspnetcoreapp/wwwroot/js/site.js @@ -0,0 +1 @@ +// Write your Javascript code. diff --git a/src/aspnetcoreapp/wwwroot/js/site.min.js b/src/aspnetcoreapp/wwwroot/js/site.min.js new file mode 100755 index 000000000..e69de29bb diff --git a/test/aspnetcoreapp.Tests/Tests.cs b/test/aspnetcoreapp.Tests/Tests.cs new file mode 100755 index 000000000..08e602fdf --- /dev/null +++ b/test/aspnetcoreapp.Tests/Tests.cs @@ -0,0 +1,61 @@ +using System; +using Xunit; +using WebApplication.Services; +using WebApplication.Models; +namespace Tests +{ + public class Tests + { + private const string expenseConvertError = "Error reading expense column data on line: {0}"; + private const string preTaxAmountConvertError = "Error reading pre tax amount column data on line: {0}"; + private const string taxtAmountConvertError = "Error reading tax amount on line: {0}"; + private const string invalidNumberOfColumnsError = "The csv file does not contain the correct number of columns"; + private const string correctExpense = @"12/1/2013,Travel,Don Draper,""783 Park Ave, New York, NY 10021"",Taxi ride, 350.00 ,NY Sales tax, 31.06"; + private const string shortLine = @"Taxi ride, 350.00 ,NY Sales tax, 31.06"; + private const string badDate = @"12013,Travel,Don Draper,""783 Park Ave, New York, NY 10021"",Taxi ride, 350.00 ,NY Sales tax, 31.06"; + private const string badPreTax = @"12/1/2013,Travel,Don Draper,""783 Park Ave, New York, NY 10021"",Taxi ride, whut ,NY Sales tax, 31.06"; + private const string badTax = @"12/1/2013,Travel,Don Draper,""783 Park Ave, New York, NY 10021"",Taxi ride, 350.00 ,NY Sales tax, huh"; + + [Fact] + public void open_correct_expense() + { + CsvImporter importer = new CsvImporter(); + var expense = importer.CreateCsvImport(correctExpense, 1); + var testExpense = new Expense(){ + Date = new DateTime(2013, 12, 1), + Category = "Travel", + EmployeeName = "Don Draper", + EmployeeAddress = "783 Park Ave, New York, NY 10021", + ExpenseDescription = "Taxi ride", + PreTaxAmount = 350.0, + TaxName = "NY Sales tax", + TaxAmount = 31.06 + }; + Assert.True(expense.Date.Equals(testExpense.Date)); + Assert.True(expense.Category.Equals(testExpense.Category)); + Assert.True(expense.EmployeeName.Equals(testExpense.EmployeeName)); + Assert.True(expense.EmployeeAddress.Equals(testExpense.EmployeeAddress)); + Assert.True(expense.ExpenseDescription.Equals(testExpense.ExpenseDescription)); + Assert.True(expense.PreTaxAmount.Equals(testExpense.PreTaxAmount)); + Assert.True(expense.TaxName.Equals(testExpense.TaxName)); + Assert.True(expense.TaxAmount.Equals(testExpense.TaxAmount)); + } + + [Fact] + public void open_short_line() + { + CsvImporter importer = new CsvImporter(); + var ex = Assert.Throws(() => importer.CreateCsvImport(shortLine, 1)); + Assert.Equal(ex.Message, invalidNumberOfColumnsError); + + ex = Assert.Throws(() => importer.CreateCsvImport(badDate, 1)); + Assert.Equal(ex.Message, string.Format(expenseConvertError, 1)); + + ex = Assert.Throws(() => importer.CreateCsvImport(badPreTax, 1)); + Assert.Equal(ex.Message, string.Format(preTaxAmountConvertError, 1)); + + ex = Assert.Throws(() => importer.CreateCsvImport(badTax, 1)); + Assert.Equal(ex.Message, string.Format(taxtAmountConvertError, 1)); + } + } +} diff --git a/test/aspnetcoreapp.Tests/project.json b/test/aspnetcoreapp.Tests/project.json new file mode 100755 index 000000000..bee4a3fc7 --- /dev/null +++ b/test/aspnetcoreapp.Tests/project.json @@ -0,0 +1,29 @@ +{ + "version": "1.0.0-*", + "buildOptions": { + "debugType": "portable" + }, + "dependencies": { + "System.Runtime.Serialization.Primitives": "4.3.0", + "xunit": "2.1.0", + "dotnet-test-xunit": "1.0.0-rc2-192208-24", + "aspnetcoreapp":{ + "target": "project" + } + }, + "testRunner": "xunit", + "frameworks": { + "netcoreapp1.1": { + "dependencies": { + "Microsoft.NETCore.App": { + "type": "platform", + "version": "1.1.0" + } + }, + "imports": [ + "dotnet5.4", + "portable-net451+win8" + ] + } + } +}