LOCK column range at specific Date (month wise) - google-apps-script

LINK TO THE SAMPLE FILE HERE I have a spreadsheet which contains multiple tabs in it on which data is filled by multiple users at a time which go direct to my master-sheet through IMPORT-range Function but this create a problem for me as a user can change Data or value at any time which further will reflect in master same which mean i have no control over the data as user can edit the values or the data according to the need.
i want to lock the value for the columns at every 15th of the EACH month
AS you can see in the Image which shown each Tab have month wise column which is to be filled by the user
Any help would be appreciated
any suggestions to it ?

Issue:
To do this, you'll have to use Google Apps Script.
A range cannot be protected for all users. At least the user executing user will still be able to edit this (this is not a feature of the solution proposed below; it's how Sheets work).
Solution:
Create a time-driven trigger that will fire the 15th of each month. This can be done manually or programmatically (by executing the function installTrigger below once, using onMonthDay). The triggered function (protectCurrentMonthColumn in the sample below) should do the following.
Get the month index and year of the current date (see Date).
Get a list of sheets to protect (retrieve all sheets via Spreadsheet.getSheets() and filter out the ones to ignore) and iterate through them.
For each sheet, get the column index of the header that contains current month date. You can compare monthIndex and year for that, and use findIndex to get the index.
Using the columnIndex, get the corresponding Range and protect it.
Code sample:
function protectCurrentMonthColumn() {
const SHEETS_TO_IGNORE = ["NDRX", "Verified NDRx"]; // Change according to your preferences
const now = new Date();
const monthIndex = now.getMonth(); // Current month
const year = now.getFullYear(); // Current year
const ss = SpreadsheetApp.getActive();
const sheetsToProtect = ss.getSheets().filter(s => !SHEETS_TO_IGNORE.includes(s.getSheetName())); // Filter out ignored sheets
sheetsToProtect.forEach(s => { // Iterate through sheets to protect
const headers = s.getRange("1:1").getValues()[0];
const columnIndex = headers.findIndex(header => { // Get index of the column to protect (header is current month and year)
return typeof header.getMonth === 'function' && header.getMonth() === monthIndex && header.getFullYear() === year;
});
if (columnIndex > -1) { // If header is not found, don't protect anything
const rangeToProtect = s.getRange(1,columnIndex+1,s.getLastRow()); // Column to protect
const protection = rangeToProtect.protect();
var me = Session.getEffectiveUser();
protection.addEditor(me);
protection.removeEditors(protection.getEditors());
if (protection.canDomainEdit()) {
protection.setDomainEdit(false);
}
}
});
}
function installTrigger() {
ScriptApp.newTrigger("protectCurrentMonthColumn")
.timeBased()
.onMonthDay(15)
.create();
}
Note:
You have to execute installTrigger once for this to work.

Related

Google Apps Script - Function Changes Cell Value Dynamically Based On Other Cells

This will likely be an easy question to answer but its an issue I run into often. Most built in google sheet functions change dynamically when cell values are updated, I need my function to do the same rather than needing to run the function to check the dates. The following script is likely messy to anyone who knows what they're doing, but it works. It is built to scan if todays date matches any in the range named "Dates" and return the value in that objects column and row of the active cell. However, this only activates when I first plug the function into the cell. I need it to change the cells value whenever one of those dates matches todays date or when none of them match todays date.
function getClass() {
var ss = SpreadsheetApp.getActive();
var sheet = ss.getActiveSheet();
var dateRanges = ss.getRangeByName("Dates");
var dateValues = dateRanges.getValues();
var studentRow = sheet.getActiveCell().getRow();
var todaysDate = Utilities.formatDate(new Date(),"00:00:00", "MM/dd/yyyy");
var datesWithCells = [];
for (i=0;i<=dateRanges.getNumColumns() -1;i++){
var date = [Utilities.formatDate(dateValues[0][i], "00:00:00", "MM/dd/yyyy")];
var col = [dateRanges.getColumn() + i]
datesWithCells.push([col,date]);
};
for (i=0;i<=dateRanges.getNumColumns() -1;i++){;
var dataCol = datesWithCells[i][0];
if (datesWithCells[i][1] == todaysDate){
return sheet.getRange(studentRow,dataCol).getValue();
} else {
return "N/A";
};
};
}
Image shows the cells correctly displaying N/A as no dates match todays date. If todays date did match, it would not change value because the function has already ran and will not update this value as needed
It is unclear why you would want to do that with a custom function, because a plain vanilla spreadsheet formula would seem to suffice:
=hlookup(today(), D2:Z, row() - row(D2) + 1, false)
To answer your question, you should not hard-code the location of the data in the custom function. Instead, pass the values to the custom function through parameters. The formula that uses a custom function will update its result automatically when its parameters change.

Automatically protect / un-protect sheets or ranges when value is change from another source (google sheets)

There is a case where test is given to a certain student. The student can only work with the answer for the test before a given period.
Here is the sample case:
https://docs.google.com/spreadsheets/d/1st1BjweeahAYrIziAibJgTj-_R_LEfOL5_CwTKg0Pzg/edit#gid=0
Sheet1!A:A is where the questions are given
Sheet1!B:B is where the answer must be written by the student
Sheet2!A1 is the condition to protect or unprotect Sheet1.
Value at Sheet2!A1 is imported from another file with IMPORTRANGE formula. So that I (as administrator) don't have to edit every single Sheet2!A1 from every students to lock their work.
Here is the link to the other master file for me to change the value at every Sheet2!A1 from every student's file: https://docs.google.com/spreadsheets/d/1qXct3QKHyYt-hQR_4-ySjrcrNSPeoai7aogKHuE-14c/edit#gid=0
When Sheet2!A1 change into 1, Sheet1!A:B need to be protected from editing by anyone unless the owner of the file
Is it possible to automatically protect sheet or range (in the example case is Sheet1!A:B) when there is "change" (maybe with on change trigger) at Sheet2!A1 (with value imported from another source)?
Note:
1 value at Sheet2!A1 is to lock Sheet1 and 0 value at Sheet2!A1 is to unlock Sheet1.
Is there any formula or google sheet script that can solve this problem?
Issue and solution:
There's no way to trigger a function when the value returned by a formula gets updated (onEdit trigger runs when a user changes the value of a cell).
As an alternative, I'd suggest using a time-based trigger, that will fire periodically (with 1 minute being the highest frequency available).
To do that, you have to install the trigger. You can do that manually, by following these steps, or programmatically, by executing the function installTrigger (see below) once (while logged in with the owner account, who will always have access to the protected range).
This trigger will then fire a function that will check whether Sheet2!A1 is 1, and protect/unprotect the range Sheet1!A:B accordingly (function updateProtection below). See Range.protect to protect the range, and getProtections and Protection.remove to unprotect it.
Code sample:
const SPREADSHEET_ID = "YOUR_SPREADSHEET_ID";
const PROTECTED_RANGE = "A:B";
function updateProtection() {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet1 = ss.getSheetByName("Sheet1");
const protections = sheet1.getProtections(SpreadsheetApp.ProtectionType.RANGE);
const sheet2 = ss.getSheetByName("Sheet2");
const condition = sheet2.getRange("A1").getValue();
if (condition == 1) { // If formula returns 1, protect
const isRangeProtected = protections.some(p => p.getRange().getA1Notation() === PROTECTED_RANGE);
if (!isRangeProtected) { // Avoid duplicate protections
const range = sheet1.getRange(PROTECTED_RANGE);
const protection = range.protect();
const me = Session.getEffectiveUser();
protection.addEditor(me);
protection.removeEditors(protection.getEditors());
if (protection.canDomainEdit()) {
protection.setDomainEdit(false);
}
}
} else { // Unprotect
protections.filter(p => p.canEdit()).forEach(p => p.remove());
}
}
function installTrigger() {
ScriptApp.newTrigger("updateProtection")
.timeBased()
.everyMinutes(1)
.create();
}

How to get a total across all sheets for cells based on criteria from another cell?

I have a budget spreadsheet with tabs for every pay period. These tabs are created as needed and don't have names I can easily know in advance. For instance, one will be "10/15 - 10/28" because that's the pay period. Next month I create a new one with "10/29 - 11/11." I'd like to be able to sum a value across all sheets. For example, every sheet has a row named "Save," some sheets have a row named "Rent", but not every sheet will contain rows with those names and when they do they won't always be in the same cell number.
Sample sheet
I've seen some examples where there's a bunch of SUMIFs and every sheet is manually named but I'd much rather not have to do that because this sheet gets copied fairly often and the sheet names will never be the same.
=SUMIFS('Tab 1' !A1:A10, 'Tab 1'!B1:B10, "Rent")
+SUMIFS('Tab 2' !A1:A10, 'Tab 2'!B1:B10, "Rent")
+SUMIFS('Tab 3' !A1:A10, 'Tab 3'!B1:B10, "Rent")
Is this possible with either a standard formula or a script?
Sample Data
Desired final tab
Column 1's values are known in advance so those can be hardcoded. For instance, there will never be a random "yet more stuff" appear which I wouldn't sum up by adding a new row to the final tab.
While there's another answer that works for this, I think the use of text finders and getRange, getValue and setFormula in loops is not the best approach, since it greatly increases the amount of calls to the spreadsheet service, slowing down the script (see Minimize calls to other services).
Method 1. onEdit trigger:
An option would be to use an onEdit trigger to do the following whenever a user edits the spreadsheet:
Loop through all sheets (excluding Totals).
For each sheet, loop through all data.
For each row, check if the category has been found previously.
If it has not been found, add it (and the corresponding amount) to an array storing the totals (called items in the function below).
If it has been found, add the current amount to the previous total.
Write the resulting data to Totals.
It could be something like this (check inline comments for more details):
const TOTAL_SHEET_NAME = "Totals";
const FIRST_ROW = 4;
function onEdit(e) {
const ss = e.source;
const targetSheet = ss.getSheetByName(TOTAL_SHEET_NAME);
const sourceSheets = ss.getSheets().filter(sheet => sheet.getName() !== TOTAL_SHEET_NAME);
let items = [["Category", "Amount"]];
sourceSheets.forEach(sheet => { // Loop through all source sheets
const values = sheet.getRange(FIRST_ROW, 1, sheet.getLastRow()-FIRST_ROW+1, 2).getValues();
values.forEach(row => { // Loop through data in a sheet
const [category, amount] = row;
const item = items.find(item => item[0] === category); // Find category
if (!item) { // If category doesn't exist, create it
items.push([category, amount]);
} else { // If category exists, update the amount
item[1] += amount;
}
});
});
targetSheet.getRange(FIRST_ROW-1, 1, items.length, items[0].length).setValues(items);
}
Method 2. Custom function:
Another option would be to use an Apps Script Custom Function.
In this case, writing the data via setValues is not necessary, returning the results would be enough:
const TOTAL_SHEET_NAME = "Totals";
const FIRST_ROW = 4;
function CALCULATE_TOTALS() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sourceSheets = ss.getSheets().filter(sheet => sheet.getName() !== TOTAL_SHEET_NAME);
let items = [["Category", "Amount"]];
sourceSheets.forEach(sheet => { // Loop through all source sheets
const values = sheet.getRange(FIRST_ROW, 1, sheet.getLastRow()-FIRST_ROW+1, 2).getValues();
values.forEach(row => { // Loop through data in a sheet
const [category, amount] = row;
const item = items.find(item => item[0] === category); // Find category
if (!item) { // If category doesn't exist, create it
items.push([category, amount]);
} else { // If category exists, update the amount
item[1] += amount;
}
});
});
return items;
}
Once the script is saved, you can use this function the same you would use any sheets built-in function:
The problem with this approach is that the formula won't recalculate automatically when changing any of the source data. In order to do that, see the above method.
Method 3. onSelectionChange trigger:
From your comment:
I'd love to be able to trigger it when the totals sheet is opened but that doesn't appear to be possible
You can do this by using an onSelectionChange trigger in combination with PropertiesService.
The idea would be that, every time a user changes cell selection, the function should check whether current sheet is Totals and whether the previously active sheet is not Totals. If that's the case, this means the user just opened the Totals sheet, and the results should update.
It could be something like this:
function onSelectionChange(e) {
const range = e.range;
const sheet = range.getSheet();
const sheetName = sheet.getName();
const previousSheetName = PropertiesService.getUserProperties().getProperty("PREVIOUS_SHEET");
if (sheetName === TOTAL_SHEET_NAME && previousSheetName !== TOTAL_SHEET_NAME) {
updateTotals(e);
}
PropertiesService.getUserProperties().setProperty("PREVIOUS_SHEET", sheetName);
}
function updateTotals(e) {
const ss = e.source;
const targetSheet = ss.getSheetByName(TOTAL_SHEET_NAME);
const sourceSheets = ss.getSheets().filter(sheet => sheet.getName() !== TOTAL_SHEET_NAME);
let items = [["Category", "Amount"]];
sourceSheets.forEach(sheet => { // Loop through all source sheets
const values = sheet.getRange(FIRST_ROW, 1, sheet.getLastRow()-FIRST_ROW+1, 2).getValues();
values.forEach(row => { // Loop through data in a sheet
const [category, amount] = row;
const item = items.find(item => item[0] === category); // Find category
if (!item) { // If category doesn't exist, create it
items.push([category, amount]);
} else { // If category exists, update the amount
item[1] += amount;
}
});
});
targetSheet.getRange(FIRST_ROW-1, 1, items.length, items[0].length).setValues(items);
}
Note: Please notice that, in order for this trigger to work, you need to refresh the spreadsheet once the trigger is added and every time the spreadsheet is opened (ref).
Reference:
onEdit(e)
Custom Functions in Google Sheets
onSelectionChange(e)
I wrote 2 scripts:
budgetTotal which takes a budgetCategory parameter, for example "Rent", and loops through all the sheets in the file to sum up the amounts listed on each sheet for that category.
budgetCreation which looks at your Totals sheet and writes these budgetTotal formulas in for each category you have listed.
I ran into a challenge which was, as I added new sheets the formulas wouldn't be aware and update the totals. So, what I did was create a simple button that executes the budgetCreation script. This way, as you add new payroll weeks you just need to press the button and - voila! - the totals update.
There might be a better way to do this using onEdit or onChange triggers but this felt like a decent starting place.
Here's a copy of the sheet with the button in place.
const ws=SpreadsheetApp.getActiveSpreadsheet()
const ss=ws.getActiveSheet()
const totals=ws.getSheetByName("Totals")
function budgetCreation(){
var budgetStart = totals.createTextFinder("Category").findNext()
var budgetStartRow = budgetStart.getRow()+1
var budgetEndRow = ss.getRange(budgetStart.getA1Notation()).getDataRegion().getLastRow()
var budgetCategoies = budgetEndRow - budgetStartRow + 1
ss.getRange(budgetStartRow,2,budgetCategoies,1).clear()
for (i=0; i<budgetCategoies; i++){
var budCat = ss.getRange(budgetStartRow+i,1).getValue()
var budFormula = `=budgetTotal(\"${budCat}\")`
ss.getRange(budgetStartRow+i,2).setFormula(budFormula)
}
}
function budgetTotal(budgetCategory) {
var sheets = ws.getSheets()
var total = 0
for (i=0; i<sheets.length; i++){
if (sheets[i].getName() != totals.getName()){
var totalFinder = sheets[i].createTextFinder(budgetCategory).findNext()
if (totalFinder == null){
total = 0
} else {
var totalValueFinder = sheets[i].getRange(totalFinder.getRow(),totalFinder.getColumn()+1).getValue()
total += totalValueFinder
}
}
}
return total
}

Automatically group new rows by time period as these are being added / recorded

So I've got this Google Sheets file where I'm using one of the sheets as a log / history for registering some values.
My time trigger which will call the function which records data to the log is set by the user. Regardless if the user chooses 1 minute interval or maybe 5, 10, 15 minutes and so on, soon the log sheet will be overpopulated. So I'd like a way for the record script I've got on the Script Editor to automatically group rows as these are being added/recorded on the log sheet automatically by the time trigger, by day and also by month simultaneously. This means Month groups and within these day subgroups and within each of these the 1 minute, or 5 etc, rows were recorded. This would improve navigation of the log sheet immensely. Is this possible?
I've got this record code on the script editor:
function RECORD_HISTORY() {
var historySheetName = "HISTORY";
var historySheet = getSheetWithName(historySheetName);
if (historySheet == null) {
historySheet = SpreadsheetApp.getActiveSpreadsheet().insertSheet(historySheetName, 1);
}
range = historySheet.getRange(2, 1, 1, 9);
var values = range.getValues();
values[0][0] = new Date();
historySheet.appendRow(values[0]);
}
function getSheetWithName(name) {
var sheets = SpreadsheetApp.getActiveSpreadsheet().getSheets();
for (var idx in sheets) {
if (sheets[idx].getName() == name) {
return sheets[idx];
}
}
return null;
}
I know there's at least one function that has the power to edit groups etc:
shiftRowGroupDepth()
All the examples of this function were used in different situations so I couldn't figure out how to go about using this function for my needs. You guys have any idea how to go about to doing this?
Here's a dummy file for you to have a better idea of my log sheet:
https://docs.google.com/spreadsheets/d/1ExXtmQ8nyuV1o_UtabVJ-TifIbORItFMWjtN6ZlruWc/
EDIT:
I want this sheet / script to do everything automatically so neither I or the user need to open the sheet and group rows manually. I don't even want to open the file in order to press a button that calls a function that groups rows. What I want is a way or script that automatically creates groups of rows by month with its own row with the name of the month: AUGUST etc. and then within/below that row/group the days, and within these day groups, the rest of the rows with the hourly logs. This row grouping function should happen automatically, while the file is closed, and the data is being recorded to the log by the function that records data which is called through a time trigger.
I want the user to have no role whatsoever in handling information, grouping etc. The user should just open the spreadsheet in order to be able to get an easy fast interpretation of the data, and be able to navigate it easily.
Also I would like the rows to be automatically collapsed after they are created and not remain expanded because if would confuse the user.
Finally would this be easier to do if the code was combined with the recording function I wrote? "RECORD_HISTORY()"
Let me know if this was clear enough. Regarding the time zone, mine is GMT+01:00.
Thanks!
function groupRow() {
const timeZone = 'Your time zone';
const sheet = SpreadsheetApp.getActiveSheet();
const rowStart = 6;
const rows = sheet.getLastRow() - rowStart + 1;
const values = sheet.getRange(rowStart, 1, rows, 1).getValues().flat();
const o = {};
values.forEach((date, i) => {
const [m, d] = Utilities.formatDate(date, timeZone, 'yyyyMM,dd').split(',');
if (!o[m]) { o[m] = {}; }
if (!o[m][d]) { o[m][d] = []; }
o[m][d].push(rowStart + i);
});
// console.log(o);
for (const m in o) { o[m] = Object.values(o[m]); }
// console.log(o);
Object.values(o).forEach(m => {
for (const d of m) {
if (d.length === 1) { continue; }
const range = `${d[1]}:${d.slice(-1)[0]}`;
// console.log('d', range);
sheet.getRange(range).shiftRowGroupDepth(1);
}
const a = m.flat();
if (a.length === 1) { return; }
const range = `${a[1]}:${a.slice(-1)[0]}`;
// console.log('m', range);
sheet.getRange(range).shiftRowGroupDepth(1);
});
}

range.getValues() With specific Date in Specific Cell

We are using a Google Script to import a Range from other Spreadsheet to another.
This helped us in the past but now the data is growing and we need to reduce the data that we import. (timeout problems)
We need to import the rows with a specific date on a specific column.
In this case, as you can see in the script below, we are importing cells from 'A1' to 'N last row' in the range variable.
What we need is that in the column 'H' from that range date is checked with something like "Date in column K >= Today()-90"
// iterate all the sheets
sourceSheetNames.forEach(function(sheetName, index) {
if (EXCLUDED_SHEETS.indexOf(sheetName) == -1) {
// get the sheet
var sheet = sourceSpreadSheet.getSheetByName(sheetName);
// selects the range of data that we want to pick. We know that row 1 is the header of the table,
// but we need to calculate which is the last row of the sheet. For that we use getLastRow() function
var lastRow = sheet.getLastRow();
// N is because we want to copy to the N column
var range = sheet.getRange('A1:N' + lastRow);
// get the values
var data = range.getValues();
data.forEach(function(value) {
value.unshift(sheetName);
});
}
});
To conditionally copy the only the rows that meet a criteria, you will want to push them to a new array if they qualify. This push would be added to your existing data.forEach() call:
...
var now = new Date();
var today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
var kept = [];
var data = range.getValues();
// Add qualifying rows to kept
data.forEach(function(row) {
var colHvalue = row[7];
var colKvalue = row[10];
if( /* your desired test */) {
// Add the name of the sheet as the row's 1st column value.
row.unshift(sheetName);
// Keep this row
kept.push(row);
}
});
/* other stuff with `kept`, like writing it to the new sheet */
You'll have to implement your specific test as you have not shared how time is stored in column H or K (e.g. days since epoch, iso time string, etc). Be sure to review the Date reference.
I've solved this in the past by adding a new column in the spreadsheet which calculates n days past an event.
=ARRAYFORMULA(IF(ISBLANK(K2:K),"",ROUNDDOWN(K2:K - NOW())))
The core of the function is the countdown calculation. For instance, today is Thursday, March 1. Subtracting it from a date in the future like Sunday, March 4, returns a whole integer: 3. I can test for that integer (or any integer) in a simple script.
In your script, add a conditional statement before executing the rest of the function:
// ...
if(someDate === -90) {
// do something...
}
This way, you're just checking the value of a cell rather than doing a calculation in a helper function. The only change (if you want a longer or shorter interval) is in the conditional test.