diff --git a/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/Configurations/App.config b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/Configurations/App.config new file mode 100644 index 0000000..066adb6 --- /dev/null +++ b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/Configurations/App.config @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/DataExporting/DataExport.cs b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/DataExporting/DataExport.cs new file mode 100644 index 0000000..bfb2198 --- /dev/null +++ b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/DataExporting/DataExport.cs @@ -0,0 +1,108 @@ +using CsvHelper; +using DocumentProcessor.JJHH17.Models; +using Spectre.Console; +using Azure.Storage.Blobs; +using System.Configuration; +using Azure.Storage.Blobs.Models; + +namespace Document.Processor.JJHH17.DataExporting; + +public class DataExport +{ + enum ExportMenuOptions + { + PDF, + CSV, + AzureBlobStorage + } + + public static void ExportMenu() + { + Console.Clear(); + + AnsiConsole.MarkupLine("[bold yellow]Export Data to a given file type[/]"); + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select a file type to export to:") + .AddChoices(Enum.GetValues())); + switch (choice) + { + case ExportMenuOptions.PDF: + CreateExportPDF(); + break; + + case ExportMenuOptions.CSV: + CreateExportCsv(); + break; + + case ExportMenuOptions.AzureBlobStorage: + CreateAzureBlobExport(); + break; + } + } + + public static void CreateExportPDF() + { + using (var context = new PhoneBookContext()) + { + var entries = context.Phonebooks.ToList(); + using (var writer = new StreamWriter("ExportedPhonebook.pdf")) + using (var csv = new CsvWriter(writer, System.Globalization.CultureInfo.InvariantCulture)) + { + csv.WriteRecords(entries); + } + } + AnsiConsole.MarkupLine("[green]Data exported to ExportedPhonebook.pdf successfully![/]"); + AnsiConsole.MarkupLine("[green]You can find the CSV in 'CodeReviews.Console.DocumentProcessor\\DocumentProcessor.JJHH17\\DocumentProcessor.JJHH17\\bin\\Debug\\net8.0\\ExportedPhonebook.pdf\'[/]"); + } + + public static void CreateExportCsv() + { + using (var context = new PhoneBookContext()) + { + var entries = context.Phonebooks.ToList(); + using (var writer = new StreamWriter("ExportedPhonebook.csv")) + using (var csv = new CsvWriter(writer, System.Globalization.CultureInfo.InvariantCulture)) + { + csv.WriteRecords(entries); + } + } + + AnsiConsole.MarkupLine("[green]Data exported to ExportedPhonebook.csv successfully![/]"); + AnsiConsole.MarkupLine("[green]You can find the CSV in 'CodeReviews.Console.DocumentProcessor\\DocumentProcessor.JJHH17\\DocumentProcessor.JJHH17\\bin\\Debug\\net8.0\\ExportedPhonebook.csv\'[/]"); + } + + public static async Task CreateAzureBlobExport() + { + var localCsv = "ExportedPhonebook.csv"; + if (!File.Exists(localCsv)) + { + AnsiConsole.MarkupLine("[yellow]CSV file not found. Creating CSV file first...[/]"); + CreateExportCsv(); + } + + var connectionString = ConfigurationManager.AppSettings["AzureBlobConnectionString"]; + var containerName = ConfigurationManager.AppSettings["ContainerName"]; + + if (string.IsNullOrEmpty(connectionString) || string.IsNullOrEmpty(containerName)) + { + AnsiConsole.MarkupLine("[red]Azure Blob Storage connection string or container name is not configured properly. Please check in the app.config file.[/]"); + return; + } + + var blobServiceClient = new BlobServiceClient(connectionString); + var containerClient = blobServiceClient.GetBlobContainerClient(containerName); + await containerClient.CreateIfNotExistsAsync(); + + var blobClient = containerClient.GetBlobClient("ExportedPhonebook.csv"); + using var fileStream = File.OpenRead(localCsv); + + var options = new BlobUploadOptions + { + HttpHeaders = new BlobHttpHeaders { ContentType = "text/csv" } + }; + + await blobClient.UploadAsync(fileStream, options); + AnsiConsole.MarkupLine("[green]CSV file uploaded to Azure Blob Storage successfully![/]"); + } +} \ No newline at end of file diff --git a/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/DataSeeding/DataSeeding.cs b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/DataSeeding/DataSeeding.cs new file mode 100644 index 0000000..eee1771 --- /dev/null +++ b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/DataSeeding/DataSeeding.cs @@ -0,0 +1,190 @@ +using DocumentProcessor.JJHH17.Models; +using ExcelDataReader; +using Spectre.Console; +using System.Data; + +namespace Document.Processor.JJHH17.DataSeeding; + +public class DataSeed +{ + enum FileTypes + { + CSV, + XLS, + XLSX + } + + public static void SeedOption() + { + Console.Clear(); + AnsiConsole.MarkupLine("[bold yellow]Seed Database via a given file type[/]"); + + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select a file type to import from:") + .AddChoices(Enum.GetValues())); + + switch (choice) + { + case FileTypes.CSV: + SeedCSVData(); + break; + case FileTypes.XLS: + SeedXLSData(); + break; + case FileTypes.XLSX: + SeedXLSXData(); + break; + } + } + + public static List ReadFile(string filePath) + { + List rows = new List(); + + try + { + string[] lines = File.ReadAllLines(filePath); + + foreach (var line in lines) + { + var values = line.Split(','); + rows.Add(values); + } + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error reading file: {ex.Message}[/]"); + Console.WriteLine("Press any key to return to the menu..."); + Console.ReadKey(); + } + + return rows; + } + + public static List ReadExcel(string filePath) + { + System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); + + using var stream = File.Open(filePath, FileMode.Open, FileAccess.Read); + using var reader = ExcelReaderFactory.CreateReader(stream); + + var dataset = reader.AsDataSet(new ExcelDataSetConfiguration + { + ConfigureDataTable = _ => new ExcelDataTableConfiguration + { + UseHeaderRow = true + } + }); + + var table = dataset.Tables[0]; + var rows = new List(); + + foreach (DataRow dr in table.Rows) + { + var name = dr["Name"].ToString()?.Trim(); + var email = dr["Email"].ToString()?.Trim(); + var phoneNumber = dr["PhoneNumber"].ToString()?.Trim(); + + rows.Add(new string[] { name, email, phoneNumber }); + } + + return rows; + } + + public static void SeedCSVData() + { + try + { + string csvFilePath = "Import Data - Sheet1.csv"; + List csvData = ReadFile(csvFilePath); + + foreach (string[] row in csvData) + { + using (var context = new PhoneBookContext()) + { + var newEntry = new Phonebook + { + Name = row[0], + Email = row[1], + PhoneNumber = row[2] + }; + context.Phonebooks.Add(newEntry); + context.SaveChanges(); + } + } + + AnsiConsole.MarkupLine("[green]Database seeded successfully from CSV![/]"); + Console.WriteLine("Press any key to return to the menu..."); + Console.ReadKey(); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error reading CSV file: {ex.Message}[/]"); + Console.WriteLine("Press any key to return to the menu..."); + Console.ReadKey(); + } + } + + public static void SeedXLSData() + { + try + { + string xlsFilePath = "Import Data - Sheet1.xls"; + var xlsData = ReadExcel(xlsFilePath); + using var context = new PhoneBookContext(); + foreach (var row in xlsData) + { + var newEntry = new Phonebook + { + Name = row[0], + Email = row[1], + PhoneNumber = row[2] + }; + context.Phonebooks.Add(newEntry); + } + context.SaveChanges(); + AnsiConsole.MarkupLine("[green]Database seeded successfully from XLS![/]"); + Console.WriteLine("Press any key to return to the menu..."); + Console.ReadKey(); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error reading XLS file: {ex.Message}[/]"); + Console.WriteLine("Press any key to return to the menu..."); + Console.ReadKey(); + } + } + + public static void SeedXLSXData() + { + try + { + string xlsxFilePath = "Import Data - Sheet1.xlsx"; + var xlsxData = ReadExcel(xlsxFilePath); + + using var context = new PhoneBookContext(); + foreach (var row in xlsxData) + { + var newEntry = new Phonebook + { + Name = row[0], + Email = row[1], + PhoneNumber = row[2] + }; + context.Phonebooks.Add(newEntry); + } + + context.SaveChanges(); + AnsiConsole.MarkupLine("[green]Database seeded successfully from XLSX![/]"); + Console.WriteLine("Press any key to return to the menu..."); + Console.ReadKey(); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error reading XLSX file: {ex.Message}[/]"); + Console.WriteLine("Press any key to return to the menu..."); + Console.ReadKey(); + } + } +} \ No newline at end of file diff --git a/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17.csproj b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17.csproj new file mode 100644 index 0000000..50bb9be --- /dev/null +++ b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17.csproj @@ -0,0 +1,30 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/Models/Phonebook.cs b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/Models/Phonebook.cs new file mode 100644 index 0000000..560092c --- /dev/null +++ b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/Models/Phonebook.cs @@ -0,0 +1,26 @@ +using System.Configuration; +using Microsoft.EntityFrameworkCore; + +namespace DocumentProcessor.JJHH17.Models; + +public class Phonebook +{ + public int Id { get; set; } + public string Name { get; set; } + public string Email { get; set; } + public string PhoneNumber { get; set; } +} + +public class PhoneBookContext : DbContext +{ + private static readonly string server = ConfigurationManager.AppSettings["Server"]; + private static readonly string databaseInstance = ConfigurationManager.AppSettings["DatabaseName"]; + public static string connectionString = $@"Server=({server})\{databaseInstance};Integrated Security=true;"; + + public DbSet Phonebooks { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseSqlServer(connectionString); + } +} \ No newline at end of file diff --git a/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/Program.cs b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/Program.cs new file mode 100644 index 0000000..5150eaf --- /dev/null +++ b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/Program.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace DocumentProcessor.JJHH17; + +public class Program +{ + public static async Task Main(string[] args) + { + var builder = Host.CreateApplicationBuilder(args); + builder.Services.AddHostedService(); + builder.Services.AddLogging(); + + using var host = builder.Build(); + var runHost = host.RunAsync(); + + var uiCts = new CancellationTokenSource(); + var uiTask = Task.Run(() => UserInterface.UserInterface.Menu(), uiCts.Token); + + await Task.WhenAny(runHost, uiTask); + + if (uiTask.IsCompleted) + { + await host.StopAsync(); + } + else + { + uiCts.Cancel(); + try { await uiTask; } + catch (OperationCanceledException) { } + + await runHost; + } + } +} \ No newline at end of file diff --git a/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/README.md b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/README.md new file mode 100644 index 0000000..95072d2 --- /dev/null +++ b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/README.md @@ -0,0 +1,97 @@ +# Document Processor Application - PhoneBook Tracker +A phonebook tracker application, allowing the user to seed and export data via local files, as well as via Azure Blob storage. + +## Project Overview +This is a project that: +- Allows the user to create and store phonebook user details. +- The user can seed data from an XLS, XLSX or CSV File (local file). +- The user can export the databases items into a CSV, XLSX or CSV file, as well as exporting to an Azure Blob Storage instance. +- The data seeding element of the application will only be presented to the user if the database contains 0 elements upon startup. +- Finally, we also run an export of data based on a scheduled task, which by default is executed daily at 09:00 am (BST / GMT UK time). +- Import and Export local files are stored in the following directory: + +```JJHH17\bin\Debug\net8.0\``` + +## Technologies Used +- SQL Server (Local DB Instance) for database storage +- Entity Framework Core +- Spectre Console (for UI / Console navigation) +- Azure Blob Storage +- Excel Data Reader (package) +- CSV Helper (CSV Writing Package) +- Configuration Manager +- CRONOS package - For running the file export on an automated basis. + +## Usage Steps +### Creating an SQL Server Local DB Instance +- This project uses a Local DB instance of SQL Server, meaning a Database file will need to be created in order for the program to run +- You can create a Local DB instance by running the following terminal command, with SQL Server installed: + +```sqllocaldb create documentProcessor``` + +### Connection strings to Local DB +- Connection strings are managed via the "app.config" file. +- Keep the "Server" string as "localdb". +- Change the "Database Name" string to whatever value you name your local db instance as. +- These values will then be appended to the full database connection string. + +### Creating and using the application +- Clone the application and open it in your IDE of choice. +- Run the migrations command for Entity Framework - this is done via the following commands: + +```dotnet ef migrations add InitialCreate``` + +then + +```dotnet ef database update``` + +This will create the relevant EF tables. + +### File Importing +- The app allows users to import data via CSV, XLSX or XLS files. +If the apps database instance is empty when the app is started, the user is prompted to import data via the selected file type: + +image + +All Files must be stored within the following directory, with the following naming conventions: + +```JJHH17\bin\Debug\net8.0\``` + +- For CSV: Import Data - Sheet1.csv +- For XLSX: Import Data - Sheet1.xlsx +- For XLS: Import Data - Sheet1.xls + +### Import File Formats: +In order for data to be imported correctly, they must be in the correct format: + +CSV: +- Name,Email Address,Telephone Number + +XLSX and XLS: +These must contain the following cell headers: +Name EmailAddress TelephoneNumber + +### File Exporting + +image + +- All files exported can be located in the following location, inside of the projects directory: +```JJHH17\bin\Debug\net8.0\``` + +- Users can export as a CSV, PDF, or route the export to an Azure Blob Storage instance (more details on this found below). + +### Automated File Export +- We use the CRONOS package for automating the export of the file export to a CSV file. +- This runs daily at 9am (BST / GMT (UK local time)). +- The class for this work can be found inside of the "Scheduled Export" folder. + +### Azure Blob Storage Connection +For data exporting, users can export the file to an Azure Blob instance. + +All connection strings can be located and adjusted in the App.Config file of the project. + +The main requirements here are: +- The connection string - This can be gathered when a Blob container has been created. +- The container name - This is the blob container name where you will store the file. + +Once these values have been added, any data found in the Database will be exported to your blob instance. diff --git a/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/ScheduledExport/ScheduledExport.cs b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/ScheduledExport/ScheduledExport.cs new file mode 100644 index 0000000..1959916 --- /dev/null +++ b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/ScheduledExport/ScheduledExport.cs @@ -0,0 +1,61 @@ +using Cronos; +using Document.Processor.JJHH17.DataExporting; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace DocumentProcessor.JJHH17.ScheduledExport; + +public class ScheduledExport +{ + +} + +public class ScheduledExportJob : BackgroundService +{ + private static string TimeZoneID = "Europe/London"; + + // Scheduled to run every day, 09:00 AM (BST / GMT) + private static readonly CronExpression cron = CronExpression.Parse("0 9 * * *"); + private static readonly TimeZoneInfo Tz = TimeZoneInfo.FindSystemTimeZoneById(TimeZoneID); + private readonly ILogger _logger; + + public ScheduledExportJob(ILogger logger) + { + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stopToken) + { + _logger.LogInformation("PDF Export is generating"); + + while (!stopToken.IsCancellationRequested) + { + var next = cron.GetNextOccurrence(DateTimeOffset.Now, Tz); + if (next is null) break; + + var delay = next.Value - DateTimeOffset.Now; + _logger.LogInformation($"Next PDF Export scheduled at {next})", next.Value); + + try + { + await Task.Delay(delay, stopToken); + } + catch (TaskCanceledException) + { + break; + } + + try + { + DataExport.CreateExportPDF(); + _logger.LogInformation("PDF Export generated successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while generating PDF Export"); + } + } + + _logger.LogInformation("Scheduled PDF Export is stopping"); + } +} \ No newline at end of file diff --git a/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/UserInterface/UserInterface.cs b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/UserInterface/UserInterface.cs new file mode 100644 index 0000000..b535e90 --- /dev/null +++ b/DocumentProcessor.JJHH17/DocumentProcessor.JJHH17/UserInterface/UserInterface.cs @@ -0,0 +1,204 @@ +using DocumentProcessor.JJHH17.Models; +using Spectre.Console; +using Document.Processor.JJHH17.DataSeeding; +using Document.Processor.JJHH17.DataExporting; + +namespace DocumentProcessor.JJHH17.UserInterface; + +public class UserInterface +{ + enum MenuOptions + { + AddEntry, + Read, + Delete, + Export, + Exit + } + + public static void Menu() + { + // Seeds data if database is empty + using (var context = new PhoneBookContext()) + { + if (!context.Phonebooks.Any()) + { + DataSeed.SeedOption(); + } + } + + + bool running = true; + while (running) + { + AnsiConsole.MarkupLine("[bold yellow]Document Processor Menu[/]"); + Console.Clear(); + + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select an option:") + .AddChoices(Enum.GetValues())); + + switch (choice) + { + case MenuOptions.AddEntry: + Console.Clear(); + AddEntry(); + break; + + case MenuOptions.Read: + Console.Clear(); + ReadEntries(); + Console.WriteLine("Press any key to return to the menu..."); + Console.ReadKey(); + break; + + case MenuOptions.Delete: + Console.Clear(); + DeleteEntry(); + break; + + case MenuOptions.Export: + Console.Clear(); + DataExport.ExportMenu(); + Console.WriteLine("Enter any key to return to the menu..."); + Console.ReadKey(); + break; + + case MenuOptions.Exit: + Console.WriteLine("Press any key to exit..."); + Console.ReadKey(); + running = false; + break; + } + } + } + + public static void AddEntry() + { + AnsiConsole.MarkupLine("[bold yellow]Add Entry[/]"); + Console.WriteLine("Enter name:"); + string name = Console.ReadLine(); + string email = EmailInput(); + string phoneNumber = PhoneNumberInput(); + + using (var context = new PhoneBookContext()) + { + var newEntry = new Phonebook + { + Name = name, + Email = email, + PhoneNumber = phoneNumber + }; + + context.Phonebooks.Add(newEntry); + context.SaveChanges(); + } + + AnsiConsole.MarkupLine("[green]Entry added successfully![/]"); + Console.WriteLine("Press any key to return to the menu..."); + Console.ReadKey(); + } + + public static void ReadEntries() + { + using (var context = new PhoneBookContext()) + { + var query = context.Phonebooks.ToList(); + + if (query.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No entries were found[/]"); + } + else + { + var table = new Table(); + table.AddColumn("ID"); + table.AddColumn("Name"); + table.AddColumn("Email"); + table.AddColumn("Phone Number"); + + foreach (var entry in query) + { + table.AddRow(entry.Id.ToString(), entry.Name, entry.Email, entry.PhoneNumber); + } + AnsiConsole.Write(table); + } + } + } + + public static void DeleteEntry() + { + using (var context = new PhoneBookContext()) + { + AnsiConsole.MarkupLine("[bold yellow]Delete Entry[/]"); + ReadEntries(); + Console.WriteLine("Enter the ID of the entry to delete:"); + + if (int.TryParse(Console.ReadLine(), out int id)) + { + var entry = context.Phonebooks.Find(id); + if (entry != null) + { + context.Phonebooks.Remove(entry); + context.SaveChanges(); + AnsiConsole.MarkupLine("[green]Entry deleted successfully![/]"); + } + else + { + AnsiConsole.MarkupLine("[red]Entry not found.[/]"); + } + } + else + { + AnsiConsole.MarkupLine("[red]Invalid ID format.[/]"); + } + + Console.WriteLine("Press any key to return to the menu..."); + Console.ReadKey(); + } + } + + public static string EmailInput() + { + string email; + while (true) + { + Console.WriteLine("Enter email address:"); + email = Console.ReadLine(); + + if (email.Contains("@") && email.Contains(".")) + { + break; + } + else + { + AnsiConsole.MarkupLine("[red]Invalid email format. Please try again.[/]"); + } + } + + return email; + } + + public static string PhoneNumberInput() + { + string phoneNumber; + while (true) + { + Console.WriteLine("Enter phone number (must be 11 digits):"); + phoneNumber = Console.ReadLine(); + + if (phoneNumber.Length == 11 && long.TryParse(phoneNumber, out _)) + { + break; + } + else + { + AnsiConsole.MarkupLine("[red]Invalid phone number. Please enter exactly 11 digits.[/]"); + } + } + + return phoneNumber; + } + +} \ No newline at end of file