diff --git a/Build/Build.bat b/Build/Build.bat
index 8e6aa4a..d29245e 100644
--- a/Build/Build.bat
+++ b/Build/Build.bat
@@ -11,7 +11,7 @@ REM restore packages
nuget.exe restore ..\
REM build install zip file
-Msbuild.exe ModuleSpecific.targets /p:VisualStudioVersion=14.0;Version=%version%;Configuration=%buildconfig%;TargetFrameworkVersion=v4.5 /t:Install /l:FileLogger,Microsoft.Build.Engine;logfile=logs\Build_%buildconfig%.log;verbosity=diagnostic
+Msbuild.exe ModuleSpecific.targets /p:VisualStudioVersion=14.0;Version=%version%;Configuration=%buildconfig%;TargetFrameworkVersion=v4.6 /t:Install /l:FileLogger,Microsoft.Build.Engine;logfile=logs\Build_%buildconfig%.log;verbosity=diagnostic
if ERRORLEVEL 1 goto end
-:end
\ No newline at end of file
+:end
diff --git a/Build/ModuleSpecific.targets b/Build/ModuleSpecific.targets
index 0a79e1f..8fca268 100644
--- a/Build/ModuleSpecific.targets
+++ b/Build/ModuleSpecific.targets
@@ -11,6 +11,7 @@
+
@@ -40,6 +41,10 @@
+
+
+
+
@@ -50,6 +55,12 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Components/Services/DelineaClient.cs b/Components/Services/DelineaClient.cs
new file mode 100644
index 0000000..0ce31fc
--- /dev/null
+++ b/Components/Services/DelineaClient.cs
@@ -0,0 +1,177 @@
+using System;
+using System.Configuration;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Threading.Tasks;
+using Newtonsoft.Json.Linq;
+
+namespace DNNStuff.SQLViewPro.Services
+{
+ ///
+ /// Minimal client for Delinea Secret Server's REST API, used to retrieve secret field
+ /// values (e.g. the Google service-account JSON key) without storing them in this codebase.
+ /// Configuration is read from web.config appSettings:
+ /// DNNStuff:SQLViewPro:DelineaBaseUrl, DNNStuff:SQLViewPro:DelineaUsername,
+ /// DNNStuff:SQLViewPro:DelineaPassword.
+ ///
+ public class DelineaClient
+ {
+ private const int TokenExpiryBufferSeconds = 60;
+
+ private static readonly object TokenLock = new object();
+ private static string _cachedAccessToken;
+ private static DateTime _cachedTokenExpiresAtUtc = DateTime.MinValue;
+
+ private readonly string _baseUrl;
+ private readonly string _username;
+ private readonly string _password;
+
+ public DelineaClient() : this(
+ ConfigurationManager.AppSettings["DNNStuff:SQLViewPro:DelineaBaseUrl"],
+ ConfigurationManager.AppSettings["DNNStuff:SQLViewPro:DelineaUsername"],
+ ConfigurationManager.AppSettings["DNNStuff:SQLViewPro:DelineaPassword"])
+ {
+ }
+
+ public DelineaClient(string baseUrl, string username, string password)
+ {
+ if (string.IsNullOrEmpty(baseUrl))
+ {
+ throw new InvalidOperationException("DNNStuff:SQLViewPro:DelineaBaseUrl is not configured in web.config appSettings.");
+ }
+ if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
+ {
+ throw new InvalidOperationException("DNNStuff:SQLViewPro:DelineaUsername / DelineaPassword are not configured in web.config appSettings.");
+ }
+
+ _baseUrl = baseUrl;
+ _username = username;
+ _password = password;
+ }
+
+ ///
+ /// Looks up a secret by name and returns the value of the field identified by
+ /// (e.g. "data" for a JSON-key style secret).
+ ///
+ public string GetFieldValue(string secretName, string fieldSlug)
+ {
+ return GetFieldValueAsync(secretName, fieldSlug).GetAwaiter().GetResult();
+ }
+
+ public async Task GetFieldValueAsync(string secretName, string fieldSlug)
+ {
+ if (string.IsNullOrEmpty(secretName))
+ {
+ throw new ArgumentException("secretName must be provided.", "secretName");
+ }
+ if (string.IsNullOrEmpty(fieldSlug))
+ {
+ throw new ArgumentException("fieldSlug must be provided.", "fieldSlug");
+ }
+
+ var accessToken = await GetAccessTokenAsync().ConfigureAwait(false);
+
+ using (var client = CreateHttpClient(accessToken))
+ {
+ var secretId = await FindSecretIdByNameAsync(client, secretName).ConfigureAwait(false);
+ return await GetSecretFieldValueAsync(client, secretId, fieldSlug).ConfigureAwait(false);
+ }
+ }
+
+ private async Task GetAccessTokenAsync()
+ {
+ lock (TokenLock)
+ {
+ if (_cachedAccessToken != null && DateTime.UtcNow < _cachedTokenExpiresAtUtc)
+ {
+ return _cachedAccessToken;
+ }
+ }
+
+ using (var client = new HttpClient())
+ {
+ var form = new FormUrlEncodedContent(new[]
+ {
+ new System.Collections.Generic.KeyValuePair("grant_type", "password"),
+ new System.Collections.Generic.KeyValuePair("username", _username),
+ new System.Collections.Generic.KeyValuePair("password", _password)
+ });
+
+ var response = await client.PostAsync(_baseUrl + "/oauth2/token", form).ConfigureAwait(false);
+ var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new InvalidOperationException(string.Format("Delinea token request failed ({0}): {1}", (int)response.StatusCode, body));
+ }
+
+ var json = JObject.Parse(body);
+ var accessToken = (string)json["access_token"];
+ var expiresIn = (int?)json["expires_in"] ?? 0;
+
+ lock (TokenLock)
+ {
+ _cachedAccessToken = accessToken;
+ _cachedTokenExpiresAtUtc = DateTime.UtcNow.AddSeconds(Math.Max(0, expiresIn - TokenExpiryBufferSeconds));
+ }
+
+ return accessToken;
+ }
+ }
+
+ private static async Task FindSecretIdByNameAsync(HttpClient client, string secretName)
+ {
+ var url = string.Format("/api/v1/secrets?filter.searchText={0}", Uri.EscapeDataString(secretName));
+ var response = await client.GetAsync(url).ConfigureAwait(false);
+ var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new InvalidOperationException(string.Format("Delinea secret search failed ({0}): {1}", (int)response.StatusCode, body));
+ }
+
+ var json = JObject.Parse(body);
+ var records = json["records"] as JArray;
+ if (records == null || records.Count == 0)
+ {
+ throw new InvalidOperationException(string.Format("Delinea secret '{0}' was not found.", secretName));
+ }
+
+ return (int)records[0]["id"];
+ }
+
+ private static async Task GetSecretFieldValueAsync(HttpClient client, int secretId, string fieldSlug)
+ {
+ var response = await client.GetAsync(string.Format("/api/v1/secrets/{0}", secretId)).ConfigureAwait(false);
+ var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new InvalidOperationException(string.Format("Delinea secret fetch failed ({0}): {1}", (int)response.StatusCode, body));
+ }
+
+ var json = JObject.Parse(body);
+ var items = json["items"] as JArray;
+ if (items != null)
+ {
+ foreach (var item in items)
+ {
+ if (string.Equals((string)item["slug"], fieldSlug, StringComparison.OrdinalIgnoreCase))
+ {
+ return (string)item["itemValue"];
+ }
+ }
+ }
+
+ throw new InvalidOperationException(string.Format("Delinea secret {0} does not have a field with slug '{1}'.", secretId, fieldSlug));
+ }
+
+ private HttpClient CreateHttpClient(string accessToken)
+ {
+ var client = new HttpClient { BaseAddress = new Uri(_baseUrl) };
+ client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
+ return client;
+ }
+ }
+}
diff --git a/Components/Services/GoogleSheets/GoogleSheetsClient.cs b/Components/Services/GoogleSheets/GoogleSheetsClient.cs
new file mode 100644
index 0000000..881f64a
--- /dev/null
+++ b/Components/Services/GoogleSheets/GoogleSheetsClient.cs
@@ -0,0 +1,256 @@
+using System;
+using System.Collections.Generic;
+using System.Configuration;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Google.Apis.Auth.OAuth2;
+using Google.Apis.Drive.v3;
+using Google.Apis.Services;
+using Google.Apis.Sheets.v4;
+using Google.Apis.Sheets.v4.Data;
+using DNNStuff.SQLViewPro.Services;
+
+namespace DNNStuff.SQLViewPro.Services.GoogleSheets
+{
+ ///
+ /// Thin wrapper around the Google Drive v3 / Sheets v4 APIs used by the
+ /// "Google Sheets Template" report type: locate a template in Drive, clone it so the
+ /// original is never mutated, write report data into it (letting formulas/pivot tables
+ /// recalculate live), export the result as .xlsx, and clean up the temporary clone.
+ ///
+ /// The service-account JSON key is never stored in this codebase - it is retrieved on
+ /// demand from Delinea Secret Server via and the resulting
+ /// authenticated Drive/Sheets service clients are cached for .
+ ///
+ public class GoogleSheetsClient
+ {
+ private static readonly TimeSpan CredentialLifetime = TimeSpan.FromHours(1);
+ private static readonly string[] Scopes = { DriveService.Scope.Drive, SheetsService.Scope.Spreadsheets };
+
+ private static readonly object CredentialLock = new object();
+ private static DriveService _cachedDriveService;
+ private static SheetsService _cachedSheetsService;
+ private static DateTime _cachedCredentialExpiresAtUtc = DateTime.MinValue;
+
+ private readonly DelineaClient _delineaClient;
+ private readonly string _secretName;
+ private readonly string _fieldSlug;
+
+ public GoogleSheetsClient() : this(new DelineaClient(),
+ ConfigurationManager.AppSettings["DNNStuff:SQLViewPro:DelineaGoogleSecretName"],
+ "json-key")
+ {
+ }
+
+ public GoogleSheetsClient(DelineaClient delineaClient, string secretName, string fieldSlug)
+ {
+ if (delineaClient == null)
+ {
+ throw new ArgumentNullException("delineaClient");
+ }
+ if (string.IsNullOrEmpty(secretName))
+ {
+ throw new InvalidOperationException("DNNStuff:SQLViewPro:DelineaGoogleSecretName is not configured in web.config appSettings, and no secret name override was supplied.");
+ }
+
+ _delineaClient = delineaClient;
+ _secretName = secretName;
+ _fieldSlug = fieldSlug;
+ }
+
+ ///
+ /// Authenticates against Google (if needed) and returns the cached Drive/Sheets
+ /// service clients. Safe to call before every operation - it is a no-op once cached.
+ ///
+ public void Authenticate()
+ {
+ lock (CredentialLock)
+ {
+ if (_cachedDriveService != null && DateTime.UtcNow < _cachedCredentialExpiresAtUtc)
+ {
+ return;
+ }
+ }
+
+ string serviceAccountJson;
+ try
+ {
+ serviceAccountJson = _delineaClient.GetFieldValue(_secretName, _fieldSlug);
+ }
+ catch (Exception ex)
+ {
+ throw new GoogleSheetsClientException(GoogleSheetsErrorType.Authentication, "Unable to retrieve the Google service-account key from Delinea Secret Server.", ex);
+ }
+
+ try
+ {
+ GoogleCredential credential;
+ using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(serviceAccountJson)))
+ {
+ credential = GoogleCredential.FromStream(stream).CreateScoped(Scopes);
+ }
+
+ var initializer = new BaseClientService.Initializer
+ {
+ HttpClientInitializer = credential,
+ ApplicationName = "DNNStuff SQLViewPro - Google Sheets Template"
+ };
+
+ lock (CredentialLock)
+ {
+ _cachedDriveService = new DriveService(initializer);
+ _cachedSheetsService = new SheetsService(initializer);
+ _cachedCredentialExpiresAtUtc = DateTime.UtcNow.Add(CredentialLifetime);
+ }
+ }
+ catch (Exception ex)
+ {
+ throw new GoogleSheetsClientException(GoogleSheetsErrorType.Authentication, "Unable to authenticate with Google using the retrieved service-account key.", ex);
+ }
+ }
+
+ private DriveService Drive
+ {
+ get { return _cachedDriveService; }
+ }
+
+ private SheetsService Sheets
+ {
+ get { return _cachedSheetsService; }
+ }
+
+ ///
+ /// Finds a template file by name within a Drive folder (non-recursive) and returns its
+ /// file id. Throws when no match exists.
+ ///
+ public string FindTemplateByName(string folderId, string templateName)
+ {
+ try
+ {
+ var request = Drive.Files.List();
+ request.Q = string.Format("'{0}' in parents and name = '{1}' and trashed = false", folderId, templateName.Replace("'", "\\'"));
+ request.Fields = "files(id, name)";
+ request.PageSize = 1;
+ // The template folder lives on a shared drive - all
+ // three of these are required together for Drive to search shared-drive content.
+ request.SupportsAllDrives = true;
+ request.IncludeItemsFromAllDrives = true;
+ request.Corpora = "allDrives";
+
+ var result = request.Execute();
+ var file = result.Files != null ? result.Files.FirstOrDefault() : null;
+ if (file == null)
+ {
+ throw new GoogleSheetsClientException(GoogleSheetsErrorType.TemplateNotFound, string.Format("Template '{0}' was not found in Drive folder '{1}'.", templateName, folderId));
+ }
+
+ return file.Id;
+ }
+ catch (Google.GoogleApiException ex)
+ {
+ throw GoogleSheetsClientException.FromGoogleApiException(GoogleSheetsErrorType.TemplateNotFound, string.Format("Error searching for template '{0}'.", templateName), ex);
+ }
+ }
+
+ ///
+ /// Clones the template spreadsheet so the original is never modified, and returns the
+ /// id of the new spreadsheet. The clone is explicitly placed in
+ /// so the caller always knows - and controls - where the temporary clone lives.
+ ///
+ public string CloneSpreadsheet(string templateFileId, string newFileName, string parentFolderId)
+ {
+ try
+ {
+ var copyMetadata = new Google.Apis.Drive.v3.Data.File { Name = newFileName };
+ if (!string.IsNullOrEmpty(parentFolderId))
+ {
+ copyMetadata.Parents = new List { parentFolderId };
+ }
+
+ var request = Drive.Files.Copy(copyMetadata, templateFileId);
+ request.Fields = "id";
+ // Required so the copy succeeds when the template lives on a shared drive.
+ request.SupportsAllDrives = true;
+ var result = request.Execute();
+ return result.Id;
+ }
+ catch (Google.GoogleApiException ex)
+ {
+ throw GoogleSheetsClientException.FromGoogleApiException(GoogleSheetsErrorType.Clone, string.Format("Error cloning template '{0}'.", templateFileId), ex);
+ }
+ }
+
+ /// Clears all values in the given A1 range (e.g. "Data!A2:Z").
+ public void ClearRange(string spreadsheetId, string range)
+ {
+ try
+ {
+ var request = Sheets.Spreadsheets.Values.Clear(new ClearValuesRequest(), spreadsheetId, range);
+ request.Execute();
+ }
+ catch (Google.GoogleApiException ex)
+ {
+ throw GoogleSheetsClientException.FromGoogleApiException(GoogleSheetsErrorType.Write, string.Format("Error clearing range '{0}'.", range), ex);
+ }
+ }
+
+ ///
+ /// Writes starting at the top-left cell of
+ /// (e.g. "Data!A1"), using USER_ENTERED so formulas typed into the range are evaluated.
+ ///
+ public void WriteData(string spreadsheetId, string range, IList> values)
+ {
+ try
+ {
+ var valueRange = new ValueRange { Values = values };
+ var request = Sheets.Spreadsheets.Values.Update(valueRange, spreadsheetId, range);
+ request.ValueInputOption = SpreadsheetsResource.ValuesResource.UpdateRequest.ValueInputOptionEnum.USERENTERED;
+ request.Execute();
+ }
+ catch (Google.GoogleApiException ex)
+ {
+ throw GoogleSheetsClientException.FromGoogleApiException(GoogleSheetsErrorType.Write, string.Format("Error writing data to range '{0}'.", range), ex);
+ }
+ }
+
+ ///
+ /// Exports the (already-recalculated) spreadsheet as an .xlsx byte array.
+ ///
+ public byte[] ExportAsXlsx(string spreadsheetId)
+ {
+ const string xlsxMimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+ try
+ {
+ var request = Drive.Files.Export(spreadsheetId, xlsxMimeType);
+ using (var ms = new MemoryStream())
+ {
+ request.Download(ms);
+ return ms.ToArray();
+ }
+ }
+ catch (Google.GoogleApiException ex)
+ {
+ throw GoogleSheetsClientException.FromGoogleApiException(GoogleSheetsErrorType.Export, string.Format("Error exporting spreadsheet '{0}' as xlsx.", spreadsheetId), ex);
+ }
+ }
+
+ ///
+ /// Best-effort cleanup of the temporary cloned spreadsheet. Callers should invoke this
+ /// from a finally block and treat failures as non-fatal (log a warning, don't fail the report).
+ ///
+ public void DeleteSpreadsheet(string spreadsheetId)
+ {
+ try
+ {
+ var request = Drive.Files.Delete(spreadsheetId);
+ request.SupportsAllDrives = true;
+ request.Execute();
+ }
+ catch (Google.GoogleApiException ex)
+ {
+ throw GoogleSheetsClientException.FromGoogleApiException(GoogleSheetsErrorType.Delete, string.Format("Error deleting temporary spreadsheet '{0}'.", spreadsheetId), ex);
+ }
+ }
+ }
+}
diff --git a/Components/Services/GoogleSheets/GoogleSheetsClientException.cs b/Components/Services/GoogleSheets/GoogleSheetsClientException.cs
new file mode 100644
index 0000000..1ce6be2
--- /dev/null
+++ b/Components/Services/GoogleSheets/GoogleSheetsClientException.cs
@@ -0,0 +1,54 @@
+using System;
+
+namespace DNNStuff.SQLViewPro.Services.GoogleSheets
+{
+ public enum GoogleSheetsErrorType
+ {
+ Authentication,
+ TemplateNotFound,
+ Clone,
+ Write,
+ Export,
+ Delete,
+ RateLimit,
+ Unknown
+ }
+
+ ///
+ /// Wraps failures from operations with a categorized
+ /// so callers (report control, logging) can react
+ /// appropriately - e.g. retry on RateLimit, surface a friendly message on TemplateNotFound.
+ ///
+ public class GoogleSheetsClientException : Exception
+ {
+ public GoogleSheetsErrorType ErrorType { get; private set; }
+
+ public GoogleSheetsClientException(GoogleSheetsErrorType errorType, string message)
+ : base(message)
+ {
+ ErrorType = errorType;
+ }
+
+ public GoogleSheetsClientException(GoogleSheetsErrorType errorType, string message, Exception innerException)
+ : base(message, innerException)
+ {
+ ErrorType = errorType;
+ }
+
+ ///
+ /// Maps a Google API exception to the appropriate error type, treating HTTP 429
+ /// (and 403 quota-exceeded) responses as .
+ ///
+ public static GoogleSheetsClientException FromGoogleApiException(GoogleSheetsErrorType defaultErrorType, string message, Google.GoogleApiException ex)
+ {
+ var errorType = defaultErrorType;
+ if ((int)ex.HttpStatusCode == 429 ||
+ (ex.HttpStatusCode == System.Net.HttpStatusCode.Forbidden && ex.Message != null && ex.Message.IndexOf("rate", StringComparison.OrdinalIgnoreCase) >= 0))
+ {
+ errorType = GoogleSheetsErrorType.RateLimit;
+ }
+
+ return new GoogleSheetsClientException(errorType, message, ex);
+ }
+ }
+}
diff --git a/DNNStuff.SQLViewPro.csproj b/DNNStuff.SQLViewPro.csproj
index 7a85b5a..e0c3f9d 100644
--- a/DNNStuff.SQLViewPro.csproj
+++ b/DNNStuff.SQLViewPro.csproj
@@ -35,7 +35,7 @@
4.0
- v4.0
+ v4.6.1
false
@@ -90,6 +90,21 @@
packages\DotNetNuke.Core.7.2.0.613\lib\net40\DotNetNuke.dll
+
+ packages\Google.Apis.1.41.1\lib\net45\Google.Apis.dll
+
+
+ packages\Google.Apis.Auth.1.41.1\lib\net45\Google.Apis.Auth.dll
+
+
+ packages\Google.Apis.Core.1.41.1\lib\net45\Google.Apis.Core.dll
+
+
+ packages\Google.Apis.Drive.v3.1.41.1.1750\lib\net45\Google.Apis.Drive.v3.dll
+
+
+ packages\Google.Apis.Sheets.v4.1.41.1.1754\lib\net45\Google.Apis.Sheets.v4.dll
+
packages\Microsoft.ApplicationBlocks.Data.2.0.0\lib\net40\Microsoft.ApplicationBlocks.Data.dll
@@ -98,10 +113,14 @@
Microsoft.VisualBasic
+
+ packages\Newtonsoft.Json.13.0.4\lib\net45\Newtonsoft.Json.dll
+
System
+
System.Data
@@ -132,6 +151,9 @@
+
+
+
diff --git a/DNNStuff.SQLViewPro.sln b/DNNStuff.SQLViewPro.sln
index 05dffad..83b9136 100644
--- a/DNNStuff.SQLViewPro.sln
+++ b/DNNStuff.SQLViewPro.sln
@@ -14,6 +14,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DNNStuff.SQLViewPro.SSRSRep
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DNNStuff.SQLViewPro.MobileParameters", "Parameters\Mobile\DNNStuff.SQLViewPro.MobileParameters.csproj", "{2E8E89C2-A59F-4DE8-B49D-437556E8F6AF}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DNNStuff.SQLViewPro.GoogleSheetsReports", "Reports\GoogleSheets\DNNStuff.SQLViewPro.GoogleSheetsReports.csproj", "{5A4B1C1A-B0B5-4621-ADA2-7BCB04605596}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -44,6 +46,10 @@ Global
{2E8E89C2-A59F-4DE8-B49D-437556E8F6AF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2E8E89C2-A59F-4DE8-B49D-437556E8F6AF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2E8E89C2-A59F-4DE8-B49D-437556E8F6AF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5A4B1C1A-B0B5-4621-ADA2-7BCB04605596}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5A4B1C1A-B0B5-4621-ADA2-7BCB04605596}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5A4B1C1A-B0B5-4621-ADA2-7BCB04605596}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5A4B1C1A-B0B5-4621-ADA2-7BCB04605596}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Parameters/Mobile/DNNStuff.SQLViewPro.MobileParameters.csproj b/Parameters/Mobile/DNNStuff.SQLViewPro.MobileParameters.csproj
index 5693c00..45de3cc 100644
--- a/Parameters/Mobile/DNNStuff.SQLViewPro.MobileParameters.csproj
+++ b/Parameters/Mobile/DNNStuff.SQLViewPro.MobileParameters.csproj
@@ -35,7 +35,7 @@
4.0
- v4.0
+ v4.6.1
false
diff --git a/Parameters/Standard/DNNStuff.SQLViewPro.StandardParameters.csproj b/Parameters/Standard/DNNStuff.SQLViewPro.StandardParameters.csproj
index 7ef5671..ec93e46 100644
--- a/Parameters/Standard/DNNStuff.SQLViewPro.StandardParameters.csproj
+++ b/Parameters/Standard/DNNStuff.SQLViewPro.StandardParameters.csproj
@@ -35,7 +35,7 @@
4.0
- v4.0
+ v4.6.1
false
diff --git a/Providers/SqlDataProvider/DNNStuff.SQLViewPro.SqlDataProvider.csproj b/Providers/SqlDataProvider/DNNStuff.SQLViewPro.SqlDataProvider.csproj
index 9c61afd..e0052d2 100644
--- a/Providers/SqlDataProvider/DNNStuff.SQLViewPro.SqlDataProvider.csproj
+++ b/Providers/SqlDataProvider/DNNStuff.SQLViewPro.SqlDataProvider.csproj
@@ -33,7 +33,7 @@
3.5
- v4.0
+ v4.6.1
diff --git a/Reports/Excel/DNNStuff.SQLViewPro.ExcelReports.csproj b/Reports/Excel/DNNStuff.SQLViewPro.ExcelReports.csproj
index 23d944c..90d42c6 100644
--- a/Reports/Excel/DNNStuff.SQLViewPro.ExcelReports.csproj
+++ b/Reports/Excel/DNNStuff.SQLViewPro.ExcelReports.csproj
@@ -36,7 +36,7 @@
4.0
true
- v4.0
+ v4.6.1
http://localhost/DNNStuff.SQLViewPro.ExcelReports/
true
Web
diff --git a/Reports/GoogleSheets/AssemblyInfo.cs b/Reports/GoogleSheets/AssemblyInfo.cs
new file mode 100644
index 0000000..30a1195
--- /dev/null
+++ b/Reports/GoogleSheets/AssemblyInfo.cs
@@ -0,0 +1,35 @@
+using System;
+
+
+using System.Reflection;
+using System.Runtime.InteropServices;
+
+
+// General Information about an assembly is controlled through the following
+// set of attributes. Change these attribute values to modify the information
+// associated with an assembly.
+
+// Review the values of the assembly attributes
+
+[assembly:AssemblyTitle("")]
+[assembly:AssemblyDescription("")]
+[assembly:AssemblyCompany("")]
+[assembly:AssemblyProduct("")]
+[assembly:AssemblyCopyright("")]
+[assembly:AssemblyTrademark("")]
+[assembly:CLSCompliant(true)]
+
+//The following GUID is for the ID of the typelib if this project is exposed to COM
+[assembly:Guid("6A2B44C8-6E5A-4F3C-9B0F-3B9F8A5A9F16")]
+
+// Version information for an assembly consists of the following four values:
+//
+// Major Version
+// Minor Version
+// Build Number
+// Revision
+//
+// You can specify all the values or you can default the Build and Revision Numbers
+// by using the '*' as shown below:
+
+[assembly:AssemblyVersion("1.0.0.0")]
diff --git a/Reports/GoogleSheets/DNNStuff.SQLViewPro.GoogleSheetsReports.csproj b/Reports/GoogleSheets/DNNStuff.SQLViewPro.GoogleSheetsReports.csproj
new file mode 100644
index 0000000..b2ee797
--- /dev/null
+++ b/Reports/GoogleSheets/DNNStuff.SQLViewPro.GoogleSheetsReports.csproj
@@ -0,0 +1,228 @@
+
+
+
+ Local
+ 9.0.30729
+ 2.0
+ {5A4B1C1A-B0B5-4621-ADA2-7BCB04605596}
+ {349c5851-65df-11da-9384-00065b846f21};{fae04ec0-301f-11d3-bf4b-00c04f79efbc}
+ Library
+ Debug
+ AnyCPU
+
+
+
+
+ DNNStuff.SQLViewPro.GoogleSheetsReports
+
+
+ None
+ JScript
+ Grid
+ IE50
+ false
+ Library
+ Binary
+ On
+ On
+
+
+
+
+
+
+ Windows
+
+
+ 4.0
+ true
+ v4.6.1
+ http://localhost/DNNStuff.SQLViewPro.GoogleSheetsReports/
+ true
+ Web
+ true
+ Foreground
+ 7
+ Days
+ false
+ false
+ true
+ 0
+ 1.0.0.%2a
+ false
+ true
+ false
+
+
+
+
+
+
+
+
+
+ bin\
+ DNNStuff.SQLViewPro.GoogleSheetsReports.xml
+ 285212672
+
+
+ DEBUG
+ true
+ true
+ true
+ false
+ false
+ false
+ false
+ 1
+ 1591,660,661
+ full
+
+
+ bin\
+ DNNStuff.SQLViewPro.GoogleSheetsReports.xml
+ 285212672
+
+
+
+
+ false
+ true
+ false
+ true
+ false
+ false
+ false
+ 1
+ 1591,660,661
+ none
+
+
+
+ ..\..\packages\DotNetNuke.Core.7.2.0.613\lib\net40\DotNetNuke.dll
+
+
+ ..\..\packages\Microsoft.ApplicationBlocks.Data.2.0.0\lib\net40\Microsoft.ApplicationBlocks.Data.dll
+
+
+
+
+ System
+
+
+ System.Data
+
+
+
+ System.Web
+
+
+
+ System.XML
+
+
+
+
+
+
+
+
+ Code
+
+
+ GoogleSheetsTemplateReportControl.ascx
+
+
+ GoogleSheetsTemplateReportControl.ascx
+ ASPXCodeBehind
+
+
+ GoogleSheetsTemplateReportControl.ascx.cs
+ Designer
+
+
+ GoogleSheetsTemplateReportSettingsControl.ascx
+
+
+ GoogleSheetsTemplateReportSettingsControl.ascx
+ ASPXCodeBehind
+
+
+ ASPXCodeBehind
+
+
+
+
+
+
+ False
+ .NET Framework 3.5 SP1 Client Profile
+ false
+
+
+ False
+ .NET Framework 2.0 %28x86%29
+ true
+
+
+ False
+ .NET Framework 3.0 %28x86%29
+ false
+
+
+ False
+ .NET Framework 3.5
+ false
+
+
+ False
+ .NET Framework 3.5 SP1
+ false
+
+
+
+
+ {0440098f-ff3b-41a4-a074-45b878f929b9}
+ DNNStuff.SQLViewPro
+
+
+
+
+
+
+
+
+
+
+ 10.0
+ $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)
+
+
+
+
+
+
+
+
+
+
+
+
+ True
+ True
+ 62053
+ /
+ http://dnn7.dnndev.me/DesktopModules/DNNStuff - SQLViewPro/Reports/GoogleSheets
+ True
+ http://dnn7.dnndev.me
+ False
+ False
+
+
+ False
+
+
+
+
+
+
diff --git a/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx b/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx
new file mode 100644
index 0000000..82036b2
--- /dev/null
+++ b/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx
@@ -0,0 +1 @@
+<%@ Control Language="C#" Inherits="DNNStuff.SQLViewPro.GoogleSheetsReports.GoogleSheetsTemplateReportControl" CodeBehind="GoogleSheetsTemplateReportControl.ascx.cs" AutoEventWireup="true" Explicit="True" targetSchema="http://schemas.microsoft.com/intellisense/ie5" %>
diff --git a/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.cs b/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.cs
new file mode 100644
index 0000000..21470ea
--- /dev/null
+++ b/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.cs
@@ -0,0 +1,178 @@
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Web.UI;
+using DotNetNuke.Common;
+using DNNStuff.SQLViewPro.Services.GoogleSheets;
+
+namespace DNNStuff.SQLViewPro.GoogleSheetsReports
+{
+ public partial class GoogleSheetsTemplateReportControl : Controls.ReportControlBase
+ {
+
+#region Web Form Designer Generated Code
+
+ //This call is required by the Web Form Designer.
+ [System.Diagnostics.DebuggerStepThrough()]private void InitializeComponent()
+ {
+
+ }
+
+ private void Page_Init(Object sender, EventArgs e)
+ {
+ //CODEGEN: This method call is required by the Web Form Designer
+ //Do not modify it using the code editor.
+ InitializeComponent();
+
+ try
+ {
+ if (Globals.IsEditMode())
+ {
+ Controls.Add(new LiteralControl("Please switch to view mode to generate the Google Sheets Template file"));
+ }
+ else
+ {
+ ProcessGoogleSheetsTemplate();
+ }
+ }
+ catch (GoogleSheetsClientException ex)
+ {
+ DotNetNuke.Services.Exceptions.Exceptions.LogException(ex);
+ Controls.Add(new LiteralControl(string.Format("Unable to generate the Google Sheets Template file ({0}): {1}", ex.ErrorType, ex.Message)));
+ }
+ catch (Exception ex)
+ {
+ DotNetNuke.Services.Exceptions.Exceptions.ProcessModuleLoadException(this, ex);
+ }
+ }
+
+#endregion
+
+#region Page
+
+ private GoogleSheetsTemplateReportSettings ReportExtra { get; set; } = new GoogleSheetsTemplateReportSettings();
+
+#endregion
+
+#region Base Method Implementations
+ public override void LoadRuntimeSettings(ReportInfo Settings)
+ {
+ ReportExtra = (GoogleSheetsTemplateReportSettings) (Serialization.DeserializeObject(Settings.ReportConfig, typeof(GoogleSheetsTemplateReportSettings)));
+ }
+#endregion
+
+#region Google Sheets Template
+ private void ProcessGoogleSheetsTemplate()
+ {
+ var ds = ReportData();
+
+ // add debug info
+ if (State.ReportSet.ReportSetDebug)
+ {
+ DebugInfo.Append(QueryText);
+ }
+
+ if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0)
+ {
+ RenderNoItems();
+ }
+ else
+ {
+ RenderGoogleSheetsTemplate(ds.Tables[0]);
+ }
+ }
+
+ private void RenderGoogleSheetsTemplate(DataTable dt)
+ {
+ var client = CreateClient();
+ client.Authenticate();
+
+ var templateFileId = client.FindTemplateByName(ReportExtra.DriveFolderId, ReportExtra.TemplateName);
+
+ var outputFileName = ReportExtra.OutputFileName.Replace("[TICKS]", DateTime.Now.Ticks.ToString());
+ var spreadsheetId = client.CloneSpreadsheet(templateFileId, outputFileName, ReportExtra.DriveFolderId);
+
+ try
+ {
+ var dataSheetName = ReportExtra.DataSheetName;
+
+ if (ReportExtra.ContainsHeaderRow)
+ {
+ // keep the existing header row - clear/write starting on row 2
+ client.ClearRange(spreadsheetId, string.Format("{0}!A2", dataSheetName));
+ client.WriteData(spreadsheetId, string.Format("{0}!A2", dataSheetName), BuildValueRows(dt, includeHeader: false));
+ }
+ else
+ {
+ // no existing header - clear the whole sheet and write our own header row
+ client.ClearRange(spreadsheetId, dataSheetName);
+ client.WriteData(spreadsheetId, string.Format("{0}!A1", dataSheetName), BuildValueRows(dt, includeHeader: true));
+ }
+
+ var xlsxBytes = client.ExportAsXlsx(spreadsheetId);
+
+ var details = new ExportDetails();
+ details.Binary = xlsxBytes;
+ details.Filename = outputFileName + ".xlsx";
+ details.Disposition = ReportExtra.DispositionType;
+
+ Session[Export.EXPORT_KEY] = details;
+
+ if (Request.ServerVariables["HTTP_USER_AGENT"].Contains("ipad") || Request.ServerVariables["HTTP_USER_AGENT"].Contains("iphone"))
+ {
+ //' no iframe for iphone, ipad
+ Response.Redirect(string.Format("{0}?ModuleId={1}&TabId={2}", ResolveUrl("~/DesktopModules/DNNStuff - SQLViewPro/Export.aspx"), State.ModuleId, State.TabId));
+ }
+ else
+ {
+ Controls.Add(new LiteralControl(string.Format("", ResolveUrl("~/DesktopModules/DNNStuff - SQLViewPro/Export.aspx"), State.ModuleId, State.TabId)));
+ }
+ }
+ finally
+ {
+ // best-effort cleanup of the temporary clone - never fail the report because of it
+ try
+ {
+ client.DeleteSpreadsheet(spreadsheetId);
+ }
+ catch (Exception ex)
+ {
+ DotNetNuke.Services.Exceptions.Exceptions.LogException(ex);
+ }
+ }
+ }
+
+ private GoogleSheetsClient CreateClient()
+ {
+ return new GoogleSheetsClient();
+ }
+
+ private static IList> BuildValueRows(DataTable dt, bool includeHeader)
+ {
+ var rows = new List>();
+
+ if (includeHeader)
+ {
+ var headerRow = new List